@capagents/forgeqe 1.3.2 → 2.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 +373 -9
- package/README.md +55 -4
- package/docs/ARCHITECTURE.md +62 -1
- package/docs/CONTAINERS.md +227 -0
- package/docs/COST_ARCHITECTURE.md +32 -0
- package/docs/DESKTOP-CI.md +87 -0
- package/docs/DESKTOP.md +387 -0
- package/docs/MOBILE.md +228 -0
- package/docs/feature-test-prompts.md +25 -0
- package/docs/security-data-flow.html +440 -3
- package/docs/user-guide.html +797 -415
- package/env.example.yaml +67 -0
- package/forgeqe.example.yaml +56 -1
- package/llms.example.json +7 -0
- package/package.json +4 -1
- package/prompts/brain.md +31 -0
- package/prompts/find-tests.md +1 -0
- package/py/forgeqe_desktop/__init__.py +1 -0
- package/py/forgeqe_desktop/__main__.py +3 -0
- package/py/forgeqe_desktop/apps.py +80 -0
- package/py/forgeqe_desktop/checks.py +168 -0
- package/py/forgeqe_desktop/cli.py +90 -0
- package/py/forgeqe_desktop/handhooks.py +293 -0
- package/py/forgeqe_desktop/handrecord.py +239 -0
- package/py/forgeqe_desktop/heal.py +107 -0
- package/py/forgeqe_desktop/models.py +208 -0
- package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
- package/py/forgeqe_desktop/record.py +150 -0
- package/py/forgeqe_desktop/replay.py +345 -0
- package/py/forgeqe_desktop/service.py +356 -0
- package/py/forgeqe_desktop/session.py +60 -0
- package/py/forgeqe_desktop/ufo_bridge.py +86 -0
- package/py/forgeqe_desktop/ufo_runner.py +305 -0
- package/py/forgeqe_desktop/visual.py +112 -0
- package/py/forgeqe_desktop/workflow.py +338 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_mobile/__init__.py +1 -0
- package/py/forgeqe_mobile/__main__.py +3 -0
- package/py/forgeqe_mobile/appium_driver.py +342 -0
- package/py/forgeqe_mobile/apps.py +84 -0
- package/py/forgeqe_mobile/cli.py +70 -0
- package/py/forgeqe_mobile/explore.py +203 -0
- package/py/forgeqe_mobile/hyperexecute.py +261 -0
- package/py/forgeqe_mobile/maestro.py +321 -0
- package/py/forgeqe_mobile/service.py +332 -0
- package/skills/testcontainers/SKILL.md +42 -0
- package/src/agents.ts +11 -2
- package/src/api/plan.ts +2 -0
- package/src/api/write.ts +2 -0
- package/src/auth/copilot.ts +130 -0
- package/src/auth/signin.ts +54 -1
- package/src/brain/index.ts +26 -4
- package/src/capabilities/guide.ts +2 -0
- package/src/capabilities/registry.ts +110 -2
- package/src/capabilities/status.ts +15 -0
- package/src/cases/rca.ts +2 -0
- package/src/childEnv.ts +4 -1
- package/src/cli/args.ts +3 -2
- package/src/cli/help.ts +24 -3
- package/src/cli/index.ts +47 -3
- package/src/code/find.ts +26 -0
- package/src/code/loop.ts +4 -1
- package/src/code/prompt.ts +2 -1
- package/src/code/prreview.ts +2 -0
- package/src/code/review.ts +6 -2
- package/src/code/runtime.ts +34 -3
- package/src/commands.ts +6 -1
- package/src/config.ts +159 -3
- package/src/containers/command.ts +68 -0
- package/src/containers/compose.ts +173 -0
- package/src/containers/config.ts +283 -0
- package/src/containers/mock.ts +156 -0
- package/src/containers/run.ts +362 -0
- package/src/containers/tests.ts +80 -0
- package/src/context.ts +33 -6
- package/src/copilot.ts +155 -0
- package/src/desktop/bridge.ts +229 -0
- package/src/desktop/command.ts +350 -0
- package/src/desktop/edit.ts +217 -0
- package/src/desktop/junit.ts +35 -0
- package/src/desktop/report.ts +155 -0
- package/src/desktop/run.ts +252 -0
- package/src/desktop/setup.ts +134 -0
- package/src/engine/bridge.ts +12 -5
- package/src/engine/setup.ts +3 -3
- package/src/environments.ts +566 -0
- package/src/evals/domain.ts +1 -0
- package/src/explore/charters.ts +2 -0
- package/src/guardrails.ts +145 -0
- package/src/heal/loop.ts +4 -1
- package/src/heal/prompt.ts +2 -1
- package/src/init.ts +5 -2
- package/src/llm.ts +23 -2
- package/src/mcp/attachtools.ts +16 -4
- package/src/mcp/captools.ts +27 -0
- package/src/mcp/containertools.ts +212 -0
- package/src/mcp/desktoptools.ts +334 -0
- package/src/mcp/mobiletools.ts +132 -0
- package/src/mcp/reporttools.ts +17 -2
- package/src/mcp/repotests.ts +22 -3
- package/src/mcp/routing.ts +11 -5
- package/src/mcp/scope.ts +210 -0
- package/src/mcp/server.ts +80 -5
- package/src/mcp/tools.ts +38 -1
- package/src/mobile/command.ts +245 -0
- package/src/mobile/run.ts +226 -0
- package/src/pack/pipeline.ts +44 -2
- package/src/refine.ts +3 -0
- package/src/report/html.ts +3 -0
- package/src/report/template.html +48 -48
- package/src/run.ts +7 -3
- package/src/tokenomics/ledger.ts +3 -1
- package/src/tui/App.tsx +23 -0
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { boolFlag, configOpts, strFlag, type Flags } from "../cli/args.ts"
|
|
6
|
+
import { terminalAsk } from "../cli/terminal.ts"
|
|
7
|
+
import { loadConfig, type AppConfig } from "../config.ts"
|
|
8
|
+
import { engineEnv, ensureEngine } from "../engine/setup.ts"
|
|
9
|
+
import type { DesktopEvent, DesktopResult } from "./bridge.ts"
|
|
10
|
+
import { junitXml, type JunitCase } from "./junit.ts"
|
|
11
|
+
import {
|
|
12
|
+
applyHeals,
|
|
13
|
+
checkApp,
|
|
14
|
+
checkDesktopConfig,
|
|
15
|
+
desktopOutDir,
|
|
16
|
+
exploreGoal,
|
|
17
|
+
hasModel,
|
|
18
|
+
parseCsv,
|
|
19
|
+
parseVariables,
|
|
20
|
+
privateDesktopSecrets,
|
|
21
|
+
recordByHand,
|
|
22
|
+
recordingNotes,
|
|
23
|
+
replayWorkflow,
|
|
24
|
+
visionProblem,
|
|
25
|
+
} from "./run.ts"
|
|
26
|
+
import { NOT_WINDOWS, desktopDoctor, ensureDesktop, ufoCommit, ufoDir } from "./setup.ts"
|
|
27
|
+
|
|
28
|
+
/** `forgeqe desktop`: automate Windows thick-client apps (docs/DESKTOP.md). */
|
|
29
|
+
|
|
30
|
+
export { applyHeals, parseVariables, privateDesktopSecrets, visionProblem }
|
|
31
|
+
|
|
32
|
+
export const DESKTOP_USAGE = `Usage:
|
|
33
|
+
forgeqe desktop setup [--ufo] [--force] set up the desktop engine (Windows); --ufo also fetches the pinned UFO²
|
|
34
|
+
forgeqe desktop apps [--env NAME] the desktop apps and secrets of the environment (env.yaml) and desktop:
|
|
35
|
+
forgeqe desktop validate <workflow.yaml> check a workflow without running it (any OS)
|
|
36
|
+
forgeqe desktop explore "<goal>" --app NAME [--name TEXT] [--out DIR] [--yes]
|
|
37
|
+
UFO² does the goal on the live app; the run, with checks of the
|
|
38
|
+
outcome, is saved as workflow.yaml
|
|
39
|
+
forgeqe desktop record --app NAME [--name TEXT] [--out DIR]
|
|
40
|
+
you do the steps in the app; your clicks and keys are saved as
|
|
41
|
+
workflow.yaml (no model). Ctrl+Shift+F11 adds a check of the control
|
|
42
|
+
under the mouse; Ctrl+Shift+F12 or closing the app stops
|
|
43
|
+
forgeqe desktop replay <workflow.yaml|folder> ... [name=value ...] [--data rows.csv] [--no-heal] [--accept-heals]
|
|
44
|
+
[--update-baselines] [--junit results.xml] [--out DIR]
|
|
45
|
+
replay workflows with no model (a folder: each workflow in it; once
|
|
46
|
+
per CSV row with --data); a step whose control moved is healed;
|
|
47
|
+
--junit writes the results for CI
|
|
48
|
+
forgeqe desktop doctor whether replay can run here unattended (engine, unlocked session)`
|
|
49
|
+
|
|
50
|
+
export function printer(color: boolean): (ev: DesktopEvent) => void {
|
|
51
|
+
const paint = (code: string, s: string) => (color ? `\u001b[${code}m${s}\u001b[0m` : s)
|
|
52
|
+
return (ev) => {
|
|
53
|
+
if (ev.type === "log" && ev.text && ev.level !== "debug") console.log(paint("2", ev.text))
|
|
54
|
+
if (ev.type === "step") {
|
|
55
|
+
const mark = ev.ok ? paint("32", "✓") : paint("31", "✗")
|
|
56
|
+
const healed = ev.healed_target ? paint("33", " (healed)") : ""
|
|
57
|
+
const tries = ev.attempts && ev.attempts > 1 ? paint("33", ` (${ev.attempts} tries)`) : ""
|
|
58
|
+
const closed = ev.dismissed?.length ? paint("33", ` (closed ${ev.dismissed.join(", ")})`) : ""
|
|
59
|
+
const note = ev.note ? paint("2", `: ${ev.note}`) : ""
|
|
60
|
+
console.log(`${mark} ${String(ev.index).padStart(3)} ${ev.op}${healed}${tries}${closed}${ev.error ? `: ${ev.error}` : note}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function abortOnSigint(): { signal: AbortSignal; done: () => void } {
|
|
66
|
+
const controller = new AbortController()
|
|
67
|
+
const onSigint = () => controller.abort()
|
|
68
|
+
process.once("SIGINT", onSigint)
|
|
69
|
+
return { signal: controller.signal, done: () => process.off("SIGINT", onSigint) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const color = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
|
|
73
|
+
|
|
74
|
+
function checkedConfig(flags: Flags, needsModel: boolean): AppConfig {
|
|
75
|
+
const cfg = loadConfig(configOpts(flags))
|
|
76
|
+
checkDesktopConfig(cfg, needsModel)
|
|
77
|
+
return cfg
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function offerHeals(workflow: string, outDir: string, heals: NonNullable<DesktopResult["heals"]>, flags: Flags, signal: AbortSignal): Promise<void> {
|
|
81
|
+
if (!heals.length) return
|
|
82
|
+
const ask = boolFlag(flags, "accept-heals") ? undefined : terminalAsk(signal)
|
|
83
|
+
const accept =
|
|
84
|
+
boolFlag(flags, "accept-heals") ||
|
|
85
|
+
(ask
|
|
86
|
+
? (await ask({
|
|
87
|
+
title: `${heals.length} step${heals.length === 1 ? "" : "s"} healed. Write the new locators into ${path.basename(workflow)}?`,
|
|
88
|
+
lines: heals.map((h) => ` step ${h.index}: ${JSON.stringify(h.old)} → ${JSON.stringify(h.new)}`),
|
|
89
|
+
options: [
|
|
90
|
+
{ id: "no", label: "No, keep the workflow as it is (heals.json has them)" },
|
|
91
|
+
{ id: "yes", label: "Yes, update the workflow" },
|
|
92
|
+
],
|
|
93
|
+
})) === "yes"
|
|
94
|
+
: false)
|
|
95
|
+
if (accept) {
|
|
96
|
+
applyHeals(workflow, heals)
|
|
97
|
+
console.log(`updated ${workflow}`)
|
|
98
|
+
} else console.log(`heals ${path.join(outDir, "heals.json")} (the workflow is unchanged; rerun with --accept-heals to write them)`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The workflows the replay arguments name: .yaml files, and the desktop workflows directly inside a folder. */
|
|
102
|
+
export function workflowFiles(paths: string[], cwd: string = process.cwd(), kind: "desktop" | "mobile" = "desktop"): string[] {
|
|
103
|
+
const format = new RegExp(`^format:\\s*["']?forgeqe-${kind}-v1["']?\\s*$`, "m")
|
|
104
|
+
const out: string[] = []
|
|
105
|
+
for (const p of paths) {
|
|
106
|
+
const full = path.resolve(cwd, p)
|
|
107
|
+
if (!existsSync(full)) throw new Error(`No workflow at ${full}`)
|
|
108
|
+
if (statSync(full).isDirectory()) {
|
|
109
|
+
const found = readdirSync(full)
|
|
110
|
+
.filter((f) => /\.ya?ml$/i.test(f))
|
|
111
|
+
.sort()
|
|
112
|
+
.map((f) => path.join(full, f))
|
|
113
|
+
.filter((f) => statSync(f).isFile() && format.test(readFileSync(f, "utf8")))
|
|
114
|
+
if (!found.length) throw new Error(`${full} has no ${kind} workflows (.yaml files with format: forgeqe-${kind}-v1)`)
|
|
115
|
+
out.push(...found)
|
|
116
|
+
} else if (/\.ya?ml$/i.test(full)) out.push(full)
|
|
117
|
+
else throw new Error(`${full} is not a workflow (.yaml or .yml) or a folder of them`)
|
|
118
|
+
}
|
|
119
|
+
return [...new Set(out)]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Replay words as workflow paths and name=value variables. */
|
|
123
|
+
export function splitReplayArgs(words: string[]): { paths: string[]; variables: Record<string, string> } {
|
|
124
|
+
const vars = words.filter((w) => /^[A-Za-z_][A-Za-z0-9_]{0,63}=/.test(w))
|
|
125
|
+
return { paths: words.filter((w) => !vars.includes(w)), variables: parseVariables(vars) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function caseDir(outDir: string, used: Set<string>, file: string): string {
|
|
129
|
+
const base = path.basename(file).replace(/\.ya?ml$/i, "").replace(/[^A-Za-z0-9_.-]/g, "_") || "workflow"
|
|
130
|
+
let name = base
|
|
131
|
+
for (let i = 2; used.has(name); i++) name = `${base}-${i}`
|
|
132
|
+
used.add(name)
|
|
133
|
+
return path.join(outDir, name)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function cmdReplay(args: string[], flags: Flags): Promise<number> {
|
|
137
|
+
const { paths, variables } = splitReplayArgs(args)
|
|
138
|
+
if (!paths.length) throw new Error(DESKTOP_USAGE)
|
|
139
|
+
const workflows = workflowFiles(paths)
|
|
140
|
+
const dataFile = strFlag(flags, "data")
|
|
141
|
+
const rows = dataFile ? parseCsv(readFileSync(path.resolve(dataFile), "utf8")) : [{}]
|
|
142
|
+
if (!rows.length) throw new Error(`${dataFile} has a header but no rows`)
|
|
143
|
+
const junit = strFlag(flags, "junit")
|
|
144
|
+
const cfg = checkedConfig(flags, false)
|
|
145
|
+
const healWanted = cfg.desktop.heal && !boolFlag(flags, "no-heal")
|
|
146
|
+
const updateBaselines = boolFlag(flags, "update-baselines")
|
|
147
|
+
if (healWanted && !hasModel(cfg)) console.error("Note: no model in llms.json, so moved controls will not be healed.")
|
|
148
|
+
await ensureDesktop((t) => console.error(t))
|
|
149
|
+
const outDir = desktopOutDir(cfg, "replay", strFlag(flags, "out"))
|
|
150
|
+
const { signal, done } = abortOnSigint()
|
|
151
|
+
const many = workflows.length > 1
|
|
152
|
+
try {
|
|
153
|
+
const results: { workflow: string; row: number; ok: boolean; report: string; error?: string }[] = []
|
|
154
|
+
const cases: JunitCase[] = []
|
|
155
|
+
const used = new Set<string>()
|
|
156
|
+
for (const workflow of workflows) {
|
|
157
|
+
const wfDir = many ? desktopOutDir(cfg, "replay", caseDir(outDir, used, workflow)) : outDir
|
|
158
|
+
const label = path.relative(process.cwd(), workflow) || workflow
|
|
159
|
+
if (many) console.log(`\n${label}`)
|
|
160
|
+
let offered = false
|
|
161
|
+
for (let i = 0; i < rows.length; i++) {
|
|
162
|
+
if (signal.aborted) break
|
|
163
|
+
const rowDir = rows.length > 1 ? desktopOutDir(cfg, "replay", path.join(wfDir, `row-${String(i + 1).padStart(3, "0")}`)) : wfDir
|
|
164
|
+
if (rows.length > 1) console.log(`\nrow ${i + 1} of ${rows.length}`)
|
|
165
|
+
const started = Date.now()
|
|
166
|
+
const run = await replayWorkflow({ cfg, workflow, variables: { ...variables, ...rows[i] }, heal: healWanted, updateBaselines, outDir: rowDir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
|
|
167
|
+
const failed = (run.result.steps || []).find((s) => !s.ok)
|
|
168
|
+
const error = run.result.error || (failed ? `step ${failed.index} (${failed.op}): ${failed.error || "failed"}` : run.result.ok ? "" : "failed")
|
|
169
|
+
if (run.result.error) console.error(run.result.error)
|
|
170
|
+
console.log(`report ${run.report}`)
|
|
171
|
+
results.push({ workflow: label, row: i + 1, ok: run.result.ok, report: run.report, ...(error ? { error } : {}) })
|
|
172
|
+
cases.push({ suite: label, name: `${run.result.name || path.basename(workflow)}${rows.length > 1 ? ` (row ${i + 1})` : ""}`, ok: run.result.ok, seconds: (Date.now() - started) / 1000, ...(error ? { error } : {}), report: run.report })
|
|
173
|
+
// Heals are offered once per workflow, from the first row that has them: every row runs the same steps.
|
|
174
|
+
if (run.result.heals?.length && !offered) {
|
|
175
|
+
offered = true
|
|
176
|
+
await offerHeals(workflow, rowDir, run.result.heals, flags, signal)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const planned = workflows.length * rows.length
|
|
181
|
+
if (planned > 1) {
|
|
182
|
+
writeFileSync(path.join(outDir, "runs.json"), JSON.stringify(results, null, 2))
|
|
183
|
+
const passed = results.filter((r) => r.ok).length
|
|
184
|
+
console.log(`\n${passed} of ${planned} runs passed${results.length < planned ? ` (${planned - results.length} not run)` : ""} ${path.join(outDir, "runs.json")}`)
|
|
185
|
+
}
|
|
186
|
+
if (junit) {
|
|
187
|
+
const file = path.resolve(junit)
|
|
188
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
189
|
+
writeFileSync(file, junitXml(cases))
|
|
190
|
+
console.log(`junit ${file}`)
|
|
191
|
+
}
|
|
192
|
+
return results.length === planned && results.every((r) => r.ok) ? 0 : 1
|
|
193
|
+
} finally {
|
|
194
|
+
done()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `forgeqe desktop doctor`: whether replay can run here, unattended: the engine set up, and a session that takes input. */
|
|
199
|
+
async function cmdDoctor(): Promise<number> {
|
|
200
|
+
const doc = desktopDoctor()
|
|
201
|
+
console.log(`engine ${doc.ready ? `ready (${doc.python})` : `not ready (${doc.error}); run forgeqe desktop setup`}`)
|
|
202
|
+
console.log(`explore ${doc.ufo ? `ready (UFO ${ufoCommit().slice(0, 12)})` : "not set up (forgeqe desktop setup --ufo)"}`)
|
|
203
|
+
if (!doc.ready) return 1
|
|
204
|
+
const r = spawnSync(doc.python, ["-m", "forgeqe_desktop", "doctor"], { env: engineEnv(), encoding: "utf8" })
|
|
205
|
+
let state: { session?: number; width?: number; height?: number; problem?: string | null } = {}
|
|
206
|
+
try {
|
|
207
|
+
state = JSON.parse((r.stdout || "").trim().split(/\r?\n/).pop() || "{}")
|
|
208
|
+
} catch {
|
|
209
|
+
state = { problem: (r.stderr || "the check did not run").trim().split(/\r?\n/).pop() }
|
|
210
|
+
}
|
|
211
|
+
console.log(`session ${state.session ?? "?"}${state.width ? `, screen ${state.width}x${state.height}` : ""}`)
|
|
212
|
+
console.log(`desktop ${state.problem ? `cannot be driven: ${state.problem}` : "logged in and unlocked"}`)
|
|
213
|
+
return state.problem ? 1 : 0
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function cmdExplore(args: string[], flags: Flags): Promise<number> {
|
|
217
|
+
const goal = args.join(" ").trim()
|
|
218
|
+
const app = strFlag(flags, "app")
|
|
219
|
+
if (!goal || !app) throw new Error(DESKTOP_USAGE)
|
|
220
|
+
const cfg = checkedConfig(flags, true)
|
|
221
|
+
checkApp(cfg, app)
|
|
222
|
+
const problem = visionProblem(cfg.activeProfile, cfg.llm)
|
|
223
|
+
if (problem) throw new Error(problem)
|
|
224
|
+
const { signal, done } = abortOnSigint()
|
|
225
|
+
try {
|
|
226
|
+
if (!boolFlag(flags, "yes")) {
|
|
227
|
+
const ask = terminalAsk(signal)
|
|
228
|
+
if (!ask) throw new Error("explore controls this desktop's mouse and keyboard; pass --yes to run it without a terminal to confirm in.")
|
|
229
|
+
const answer = await ask({
|
|
230
|
+
title: `UFO² will control this desktop's mouse and keyboard to: ${goal}`,
|
|
231
|
+
detail: `It starts ${app}, acts in its windows only (no shell commands), and its model is ${cfg.activeProfile}. Use a test machine and a test account.`,
|
|
232
|
+
options: [
|
|
233
|
+
{ id: "no", label: "Cancel" },
|
|
234
|
+
{ id: "yes", label: "Start" },
|
|
235
|
+
],
|
|
236
|
+
})
|
|
237
|
+
if (answer !== "yes") return 1
|
|
238
|
+
}
|
|
239
|
+
const outDir = desktopOutDir(cfg, "explore", strFlag(flags, "out"))
|
|
240
|
+
const { result, report } = await exploreGoal({ cfg, goal, app, name: strFlag(flags, "name"), outDir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
|
|
241
|
+
if (result.error) console.error(result.error)
|
|
242
|
+
console.log(`\nreport ${report}`)
|
|
243
|
+
if (result.workflow) {
|
|
244
|
+
console.log(`workflow ${result.workflow} (${result.recorded} steps, ${result.checks ?? 0} checks)`)
|
|
245
|
+
console.log(`replay forgeqe desktop replay "${result.workflow}"`)
|
|
246
|
+
}
|
|
247
|
+
return result.ok ? 0 : 1
|
|
248
|
+
} finally {
|
|
249
|
+
done()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function cmdRecord(flags: Flags): Promise<number> {
|
|
254
|
+
const app = strFlag(flags, "app")
|
|
255
|
+
if (!app) throw new Error(DESKTOP_USAGE)
|
|
256
|
+
const cfg = checkedConfig(flags, false)
|
|
257
|
+
checkApp(cfg, app)
|
|
258
|
+
const outDir = desktopOutDir(cfg, "record", strFlag(flags, "out"))
|
|
259
|
+
const { signal, done } = abortOnSigint()
|
|
260
|
+
try {
|
|
261
|
+
console.log(`Starting ${app}. Do the steps in it; Ctrl+Shift+F11 over a control adds a check of what it shows; Ctrl+Shift+F12 or closing the app stops.`)
|
|
262
|
+
const onEvent = (ev: DesktopEvent) => {
|
|
263
|
+
if (ev.type === "recorded") console.log(`${paintIf("36", "●")} ${String(ev.index).padStart(3)} ${ev.op}${ev.keys ? ` ${ev.keys}` : ""}${ev.target ? ` ${String(ev.target.name || ev.target.automation_id || "")}` : ""}`)
|
|
264
|
+
else printer(color())(ev)
|
|
265
|
+
}
|
|
266
|
+
const { result, report } = await recordByHand({ cfg, app, name: strFlag(flags, "name"), outDir, signal, onEvent, log: (t) => console.error(t) })
|
|
267
|
+
if (result.error) console.error(result.error)
|
|
268
|
+
console.log(`\nreport ${report}`)
|
|
269
|
+
if (result.workflow) {
|
|
270
|
+
console.log(`workflow ${result.workflow} (${result.recorded} steps, ${result.checks ?? 0} checks)`)
|
|
271
|
+
for (const s of result.skipped || []) console.log(` not recorded: ${s.action}: ${s.reason}`)
|
|
272
|
+
for (const n of recordingNotes(cfg, result)) console.log(`note ${n}`)
|
|
273
|
+
console.log(`replay forgeqe desktop replay "${result.workflow}"`)
|
|
274
|
+
}
|
|
275
|
+
return result.ok ? 0 : 1
|
|
276
|
+
} finally {
|
|
277
|
+
done()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function paintIf(code: string, s: string): string {
|
|
282
|
+
return color() ? `\u001b[${code}m${s}\u001b[0m` : s
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function cmdValidate(args: string[]): Promise<number> {
|
|
286
|
+
const file = args[0]
|
|
287
|
+
if (!file) throw new Error(DESKTOP_USAGE)
|
|
288
|
+
// Any Python with PyYAML runs validate: the desktop engine's on Windows, else the browser engine's.
|
|
289
|
+
const desktop = desktopDoctor()
|
|
290
|
+
const python = desktop.ready ? desktop.python : await ensureEngine((t) => console.error(t))
|
|
291
|
+
const r = spawnSync(python, ["-m", "forgeqe_desktop", "validate", path.resolve(file)], { env: engineEnv(), encoding: "utf8" })
|
|
292
|
+
if (r.error) throw r.error
|
|
293
|
+
process.stdout.write(r.stdout || "")
|
|
294
|
+
process.stderr.write(r.stderr || "")
|
|
295
|
+
return r.status ?? 1
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function cmdApps(flags: Flags): number {
|
|
299
|
+
const cfg = loadConfig(configOpts(flags))
|
|
300
|
+
const apps = Object.entries(cfg.desktop.apps)
|
|
301
|
+
if (!apps.length) {
|
|
302
|
+
console.log("No apps in forgeqe.yaml desktop.apps. Add one:\n\ndesktop:\n apps:\n billing:\n path: C:\\Program Files\\Contoso\\Billing\\Billing.exe\n args: []\n secrets: [BILLING_PASSWORD]")
|
|
303
|
+
return 0
|
|
304
|
+
}
|
|
305
|
+
for (const [name, raw] of apps) {
|
|
306
|
+
const r = (raw && typeof raw === "object" ? raw : {}) as { path?: unknown; args?: unknown }
|
|
307
|
+
console.log(`${name.padEnd(16)} ${String(r.path ?? "(no path)")}${Array.isArray(r.args) && r.args.length ? ` ${JSON.stringify(r.args)}` : ""}`)
|
|
308
|
+
}
|
|
309
|
+
console.log(`\nsecrets ${cfg.desktop.secrets.join(", ") || "none"}`)
|
|
310
|
+
const own = privateDesktopSecrets(cfg)
|
|
311
|
+
if (own.length) console.log(` ! ${own.join(", ")} ${own.length === 1 ? "is" : "are"} ForgeQE's own; remove from desktop.secrets`)
|
|
312
|
+
return 0
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function cmdSetup(flags: Flags): Promise<number> {
|
|
316
|
+
const python = await ensureDesktop((t) => console.log(t), { ufo: boolFlag(flags, "ufo"), force: boolFlag(flags, "force") })
|
|
317
|
+
const doc = desktopDoctor(python)
|
|
318
|
+
console.log(`python ${python}`)
|
|
319
|
+
console.log(`replay ${doc.ready ? "ready" : `not ready (${doc.error})`}`)
|
|
320
|
+
console.log(`explore ${doc.ufo ? `ready (UFO ${ufoCommit().slice(0, 12)} in ${ufoDir()})` : "not set up (run forgeqe desktop setup --ufo)"}`)
|
|
321
|
+
return doc.ready ? 0 : 1
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function cmdDesktop(args: string[], flags: Flags): Promise<number> {
|
|
325
|
+
const [sub, ...rest] = args
|
|
326
|
+
const windowsOnly = sub === "setup" || sub === "replay" || sub === "explore" || sub === "record" || sub === "doctor"
|
|
327
|
+
if (windowsOnly && process.platform !== "win32") {
|
|
328
|
+
console.error(NOT_WINDOWS)
|
|
329
|
+
return 2
|
|
330
|
+
}
|
|
331
|
+
switch (sub) {
|
|
332
|
+
case "setup":
|
|
333
|
+
return cmdSetup(flags)
|
|
334
|
+
case "apps":
|
|
335
|
+
return cmdApps(flags)
|
|
336
|
+
case "validate":
|
|
337
|
+
return cmdValidate(rest)
|
|
338
|
+
case "doctor":
|
|
339
|
+
return cmdDoctor()
|
|
340
|
+
case "replay":
|
|
341
|
+
return cmdReplay(rest, flags)
|
|
342
|
+
case "explore":
|
|
343
|
+
return cmdExplore(rest, flags)
|
|
344
|
+
case "record":
|
|
345
|
+
return cmdRecord(flags)
|
|
346
|
+
default:
|
|
347
|
+
console.log(DESKTOP_USAGE)
|
|
348
|
+
return sub ? 2 : 0
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import YAML, { isMap, isSeq, type Document, type YAMLMap, type YAMLSeq } from "yaml"
|
|
7
|
+
|
|
8
|
+
import { engineEnv, ensureEngine } from "../engine/setup.ts"
|
|
9
|
+
import { lineDiff } from "../prompts.ts"
|
|
10
|
+
import { desktopDoctor } from "./setup.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Changes to a desktop workflow that ForgeQE's agent proposes from chat (a longer timeout, a wait_for step, a dialog to
|
|
14
|
+
* dismiss, a healed locator), applied to the YAML with its comments and layout kept. Each change has a fixed shape and
|
|
15
|
+
* the result is checked by the engine's own validator before anything is written; the caller shows the diff and asks.
|
|
16
|
+
*
|
|
17
|
+
* Step numbers are the workflow's as it is now (the report's numbers), whatever earlier changes in the same call do.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const STEP_OPS = [
|
|
21
|
+
"launch", "click", "double_click", "type", "select", "keys", "expect", "expect_image", "wait", "wait_for", "close",
|
|
22
|
+
// mobile workflows (forgeqe-mobile-v1); the engine's validator checks each op belongs to the workflow's format
|
|
23
|
+
"tap", "double_tap", "long_press", "swipe", "scroll_to", "back", "hide_keyboard",
|
|
24
|
+
] as const
|
|
25
|
+
const TIMING_FIELDS = ["timeout", "retries"] as const
|
|
26
|
+
const STEP_FIELDS = ["target", "text", "value", "keys", "seconds", "tolerance", "gone", "image", "ignore", "note", "timeout", "retries", "direction"] as const
|
|
27
|
+
const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
|
|
28
|
+
const MAX_CHANGES = 20
|
|
29
|
+
|
|
30
|
+
export type WorkflowChange =
|
|
31
|
+
| { op: "set"; field: (typeof TIMING_FIELDS)[number]; value: number; step?: number }
|
|
32
|
+
| { op: "unset"; field: (typeof TIMING_FIELDS)[number]; step?: number }
|
|
33
|
+
| { op: "set_step"; step: number; field: (typeof STEP_FIELDS)[number]; value: unknown }
|
|
34
|
+
| { op: "add_dismiss"; target: Record<string, unknown> }
|
|
35
|
+
| { op: "insert_step"; before?: number; after?: number; step: Record<string, unknown> }
|
|
36
|
+
| { op: "remove_step"; step: number }
|
|
37
|
+
| { op: "set_variable"; name: string; value: string }
|
|
38
|
+
|
|
39
|
+
export type WorkflowEdit = { before: string; after: string; diff: string[]; summary: string[] }
|
|
40
|
+
|
|
41
|
+
/** The engine's validator: null when the text is a valid workflow, else why not. */
|
|
42
|
+
export type ValidateWorkflow = (text: string) => Promise<string | null>
|
|
43
|
+
|
|
44
|
+
const isObject = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === "object" && !Array.isArray(v)
|
|
45
|
+
|
|
46
|
+
function stepNumber(v: unknown, count: number, what: string): number {
|
|
47
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > count) throw new Error(`${what} must be a step number from 1 to ${count}`)
|
|
48
|
+
return v
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The changes as given by the caller (the model): each checked against its shape; anything else is refused. */
|
|
52
|
+
export function checkChanges(raw: unknown, count: number): WorkflowChange[] {
|
|
53
|
+
if (!Array.isArray(raw) || !raw.length) throw new Error("changes must be a non-empty list")
|
|
54
|
+
if (raw.length > MAX_CHANGES) throw new Error(`at most ${MAX_CHANGES} changes in one edit`)
|
|
55
|
+
return raw.map((c, i) => {
|
|
56
|
+
const where = `change ${i + 1}`
|
|
57
|
+
if (!isObject(c)) throw new Error(`${where} must be an object`)
|
|
58
|
+
const allow = (keys: string[]) => {
|
|
59
|
+
const extra = Object.keys(c).filter((k) => !keys.includes(k))
|
|
60
|
+
if (extra.length) throw new Error(`${where} (${String(c.op)}): unknown keys ${extra.join(", ")}`)
|
|
61
|
+
}
|
|
62
|
+
switch (c.op) {
|
|
63
|
+
case "set":
|
|
64
|
+
case "unset": {
|
|
65
|
+
allow(c.op === "set" ? ["op", "field", "value", "step"] : ["op", "field", "step"])
|
|
66
|
+
if (!TIMING_FIELDS.includes(c.field as never)) throw new Error(`${where}: field must be ${TIMING_FIELDS.join(" or ")}`)
|
|
67
|
+
if (c.op === "set" && (typeof c.value !== "number" || !Number.isFinite(c.value))) throw new Error(`${where}: value must be a number`)
|
|
68
|
+
const step = c.step === undefined ? undefined : stepNumber(c.step, count, `${where}: step`)
|
|
69
|
+
return { op: c.op, field: c.field, ...(c.op === "set" ? { value: c.value } : {}), ...(step ? { step } : {}) } as WorkflowChange
|
|
70
|
+
}
|
|
71
|
+
case "set_step":
|
|
72
|
+
allow(["op", "step", "field", "value"])
|
|
73
|
+
if (!STEP_FIELDS.includes(c.field as never)) throw new Error(`${where}: field must be one of ${STEP_FIELDS.join(", ")}`)
|
|
74
|
+
if (c.value === undefined) throw new Error(`${where}: value is required`)
|
|
75
|
+
return { op: "set_step", step: stepNumber(c.step, count, `${where}: step`), field: c.field, value: c.value } as WorkflowChange
|
|
76
|
+
case "add_dismiss":
|
|
77
|
+
allow(["op", "target"])
|
|
78
|
+
if (!isObject(c.target)) throw new Error(`${where}: target must be the dialog button's UIA properties`)
|
|
79
|
+
return { op: "add_dismiss", target: c.target }
|
|
80
|
+
case "insert_step": {
|
|
81
|
+
allow(["op", "before", "after", "step"])
|
|
82
|
+
if ((c.before === undefined) === (c.after === undefined)) throw new Error(`${where}: give before or after, a step number`)
|
|
83
|
+
if (!isObject(c.step) || !STEP_OPS.includes(c.step.op as never)) throw new Error(`${where}: step must be a workflow step with op one of ${STEP_OPS.join(", ")}`)
|
|
84
|
+
const at = c.before !== undefined ? { before: stepNumber(c.before, count, `${where}: before`) } : { after: stepNumber(c.after, count, `${where}: after`) }
|
|
85
|
+
return { op: "insert_step", ...at, step: c.step }
|
|
86
|
+
}
|
|
87
|
+
case "remove_step":
|
|
88
|
+
allow(["op", "step"])
|
|
89
|
+
return { op: "remove_step", step: stepNumber(c.step, count, `${where}: step`) }
|
|
90
|
+
case "set_variable":
|
|
91
|
+
allow(["op", "name", "value"])
|
|
92
|
+
if (typeof c.name !== "string" || !VAR_NAME.test(c.name)) throw new Error(`${where}: name must be letters, digits and _`)
|
|
93
|
+
if (typeof c.value !== "string" && typeof c.value !== "number") throw new Error(`${where}: value must be text`)
|
|
94
|
+
return { op: "set_variable", name: c.name, value: String(c.value) }
|
|
95
|
+
default:
|
|
96
|
+
throw new Error(`${where}: op must be set, unset, set_step, add_dismiss, insert_step, remove_step or set_variable`)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function describe(c: WorkflowChange): string {
|
|
102
|
+
switch (c.op) {
|
|
103
|
+
case "set":
|
|
104
|
+
return `${c.step ? `step ${c.step}` : "the workflow"}: ${c.field} ${c.value}`
|
|
105
|
+
case "unset":
|
|
106
|
+
return `${c.step ? `step ${c.step}` : "the workflow"}: no ${c.field}`
|
|
107
|
+
case "set_step":
|
|
108
|
+
return `step ${c.step}: ${c.field} = ${JSON.stringify(c.value)}`
|
|
109
|
+
case "add_dismiss":
|
|
110
|
+
return `dismiss ${JSON.stringify(c.target)} whenever it is in the way`
|
|
111
|
+
case "insert_step":
|
|
112
|
+
return `new ${String(c.step.op)} step ${c.before ? `before step ${c.before}` : `after step ${c.after}`}`
|
|
113
|
+
case "remove_step":
|
|
114
|
+
return `remove step ${c.step}`
|
|
115
|
+
case "set_variable":
|
|
116
|
+
return `variable ${c.name} = ${JSON.stringify(c.value)}`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Apply the changes to the workflow's text; comments and layout are kept. Throws on a change that cannot apply. */
|
|
121
|
+
export function applyChanges(text: string, changes: WorkflowChange[]): string {
|
|
122
|
+
const doc: Document = YAML.parseDocument(text)
|
|
123
|
+
if (doc.errors.length) throw new Error(`the workflow is not valid YAML: ${doc.errors[0]!.message}`)
|
|
124
|
+
const steps = doc.get("steps", true)
|
|
125
|
+
if (!isSeq(steps)) throw new Error("the workflow has no steps list")
|
|
126
|
+
const seq = steps as YAMLSeq
|
|
127
|
+
const original = [...seq.items]
|
|
128
|
+
const at = (n: number): YAMLMap => {
|
|
129
|
+
const pos = seq.items.indexOf(original[n - 1]!)
|
|
130
|
+
if (pos < 0) throw new Error(`step ${n} was removed by an earlier change`)
|
|
131
|
+
const node = seq.items[pos]
|
|
132
|
+
if (!isMap(node)) throw new Error(`step ${n} is not a mapping`)
|
|
133
|
+
return node as YAMLMap
|
|
134
|
+
}
|
|
135
|
+
for (const c of changes) {
|
|
136
|
+
switch (c.op) {
|
|
137
|
+
case "set":
|
|
138
|
+
if (c.step) at(c.step).set(c.field, c.value)
|
|
139
|
+
else doc.set(c.field, c.value)
|
|
140
|
+
break
|
|
141
|
+
case "unset":
|
|
142
|
+
if (c.step) at(c.step).delete(c.field)
|
|
143
|
+
else doc.delete(c.field)
|
|
144
|
+
break
|
|
145
|
+
case "set_step":
|
|
146
|
+
at(c.step).set(c.field, doc.createNode(c.value, { flow: isObject(c.value) || Array.isArray(c.value) }))
|
|
147
|
+
break
|
|
148
|
+
case "add_dismiss": {
|
|
149
|
+
let list = doc.get("dismiss", true)
|
|
150
|
+
if (!isSeq(list)) {
|
|
151
|
+
doc.set("dismiss", doc.createNode([]))
|
|
152
|
+
list = doc.get("dismiss", true)
|
|
153
|
+
}
|
|
154
|
+
;(list as YAMLSeq).add(doc.createNode(c.target, { flow: true }))
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
case "insert_step": {
|
|
158
|
+
const anchor = at((c.before ?? c.after)!)
|
|
159
|
+
const pos = seq.items.indexOf(anchor) + (c.after ? 1 : 0)
|
|
160
|
+
const node = doc.createNode(c.step) as YAMLMap
|
|
161
|
+
for (const pair of node.items) if (isMap(pair.value) || isSeq(pair.value)) pair.value.flow = true
|
|
162
|
+
seq.items.splice(pos, 0, node)
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
case "remove_step": {
|
|
166
|
+
const node = at(c.step)
|
|
167
|
+
seq.items.splice(seq.items.indexOf(node), 1)
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
case "set_variable": {
|
|
171
|
+
if (!isMap(doc.get("variables", true))) doc.set("variables", doc.createNode({}))
|
|
172
|
+
doc.setIn(["variables", c.name], c.value)
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return doc.toString()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The proposed edit of `file`: before, after, diff and a line per change; checked by `validate`, nothing written. */
|
|
181
|
+
export async function proposeEdit(file: string, rawChanges: unknown, validate: ValidateWorkflow): Promise<WorkflowEdit> {
|
|
182
|
+
const before = readFileSync(file, "utf8")
|
|
183
|
+
const parsed = YAML.parse(before) as { steps?: unknown[] } | null
|
|
184
|
+
const count = Array.isArray(parsed?.steps) ? parsed!.steps.length : 0
|
|
185
|
+
const changes = checkChanges(rawChanges, count)
|
|
186
|
+
const after = applyChanges(before, changes)
|
|
187
|
+
const problem = await validate(after)
|
|
188
|
+
if (problem) throw new Error(`The changed workflow would not run: ${problem}`)
|
|
189
|
+
return { before, after, diff: lineDiff(before.replace(/\n$/, ""), after.replace(/\n$/, "")), summary: changes.map(describe) }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Write an accepted edit, keeping the old text in `backupDir`; refuses when the file changed since it was proposed. */
|
|
193
|
+
export function writeEdit(file: string, edit: WorkflowEdit, backupDir: string): string {
|
|
194
|
+
if (readFileSync(file, "utf8") !== edit.before) throw new Error(`${path.basename(file)} changed since the edit was proposed; propose it again.`)
|
|
195
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
|
|
196
|
+
const backup = path.join(backupDir, `${stamp}-${path.basename(file)}`)
|
|
197
|
+
writeFileSync(backup, edit.before)
|
|
198
|
+
writeFileSync(file, edit.after)
|
|
199
|
+
return backup
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The engine's own validator (`python -m forgeqe_desktop validate`), run on the text in a temporary file. */
|
|
203
|
+
export const engineValidate: ValidateWorkflow = async (text) => {
|
|
204
|
+
const desktop = desktopDoctor()
|
|
205
|
+
const python = desktop.ready ? desktop.python : await ensureEngine(() => {})
|
|
206
|
+
const dir = mkdtempSync(path.join(tmpdir(), "fq-wf-"))
|
|
207
|
+
try {
|
|
208
|
+
const file = path.join(dir, "workflow.yaml")
|
|
209
|
+
writeFileSync(file, text)
|
|
210
|
+
const r = spawnSync(python, ["-m", "forgeqe_desktop", "validate", file], { env: engineEnv(), encoding: "utf8" })
|
|
211
|
+
if (r.error) throw r.error
|
|
212
|
+
if (r.status === 0) return null
|
|
213
|
+
return ((r.stderr || r.stdout || "").trim().split(/\r?\n/).pop() || "it is not a valid workflow").replace(/^error:\s*/, "")
|
|
214
|
+
} finally {
|
|
215
|
+
rmSync(dir, { recursive: true, force: true })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JUnit XML for a desktop suite (`forgeqe desktop replay ... --junit FILE`), the format CI servers show as test
|
|
3
|
+
* results. Every value is escaped: step errors and control names come from the app under test.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type JunitCase = { suite: string; name: string; ok: boolean; seconds: number; error?: string; report?: string }
|
|
7
|
+
|
|
8
|
+
/** `value` as XML text or attribute: markup escaped, and characters XML 1.0 cannot hold dropped. */
|
|
9
|
+
export function xmlEscape(value: unknown): string {
|
|
10
|
+
return String(value ?? "")
|
|
11
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "")
|
|
12
|
+
.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "")
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, """)
|
|
17
|
+
.replace(/'/g, "'")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function junitXml(cases: JunitCase[], name = "forgeqe desktop"): string {
|
|
21
|
+
const failures = cases.filter((c) => !c.ok).length
|
|
22
|
+
const seconds = cases.reduce((n, c) => n + c.seconds, 0)
|
|
23
|
+
const body = cases
|
|
24
|
+
.map((c) => {
|
|
25
|
+
const out = c.report ? `\n <system-out>${xmlEscape(`report ${c.report}`)}</system-out>` : ""
|
|
26
|
+
const fail = c.ok ? "" : `\n <failure message="${xmlEscape((c.error || "failed").split(/\r?\n/)[0]!.slice(0, 500))}">${xmlEscape(c.error || "failed")}</failure>`
|
|
27
|
+
return ` <testcase classname="${xmlEscape(c.suite)}" name="${xmlEscape(c.name)}" time="${c.seconds.toFixed(3)}">${fail}${out}\n </testcase>`
|
|
28
|
+
})
|
|
29
|
+
.join("\n")
|
|
30
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<testsuite name="${xmlEscape(name)}" tests="${cases.length}" failures="${failures}" errors="0" time="${seconds.toFixed(3)}">
|
|
32
|
+
${body}
|
|
33
|
+
</testsuite>
|
|
34
|
+
`
|
|
35
|
+
}
|