@capagents/forgeqe 1.3.1 → 2.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 +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/cli.py +10 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_engine/summary.py +6 -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/defects.ts +2 -1
- 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 +6 -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/update.ts +118 -8
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, readFileSync, 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 type { DesktopEvent } from "../desktop/bridge.ts"
|
|
9
|
+
import { abortOnSigint, color, offerHeals, printer, splitReplayArgs, workflowFiles } from "../desktop/command.ts"
|
|
10
|
+
import { junitXml, type JunitCase } from "../desktop/junit.ts"
|
|
11
|
+
import { parseCsv, visionProblem } from "../desktop/run.ts"
|
|
12
|
+
import { engineEnv, ensureEngine } from "../engine/setup.ts"
|
|
13
|
+
import { withEnvironment } from "../environments.ts"
|
|
14
|
+
import { checkMobileApp, checkMobileConfig, exploreMobile, hasModel, maestroFlowFiles, mobileDoctor, mobileOutDir, replayMobile, runMaestroFlows, uploadToTestmu } from "./run.ts"
|
|
15
|
+
|
|
16
|
+
/** `forgeqe mobile`: Android and iOS apps through Appium or Maestro (docs/MOBILE.md). */
|
|
17
|
+
|
|
18
|
+
export const MOBILE_USAGE = `Usage:
|
|
19
|
+
forgeqe mobile apps [--env NAME] the mobile apps of the environment (env.yaml) and mobile:
|
|
20
|
+
forgeqe mobile doctor whether the Appium server answers and maestro is installed
|
|
21
|
+
forgeqe mobile explore "<goal>" --app NAME [--name TEXT] [--out DIR] [--yes]
|
|
22
|
+
the model does the goal in the app through Appium; what it did is
|
|
23
|
+
saved as workflow.yaml, with checks of the outcome
|
|
24
|
+
forgeqe mobile replay <workflow.yaml|folder> ... [name=value ...] [--driver appium|maestro] [--data rows.csv]
|
|
25
|
+
[--no-heal] [--accept-heals] [--update-baselines] [--junit results.xml] [--out DIR]
|
|
26
|
+
replay mobile workflows with no model (Appium heals a moved control)
|
|
27
|
+
forgeqe mobile maestro <flow.yaml|folder> ... [--junit results.xml] [--out DIR]
|
|
28
|
+
run your team's Maestro flows, after checking them against the allowlist
|
|
29
|
+
forgeqe mobile upload <app.apk|.ipa> [--name TEXT] [--virtual]
|
|
30
|
+
upload a build to TestMu AI; prints the lt:// id for the app's \`app\`
|
|
31
|
+
forgeqe mobile flow <workflow.yaml> print a mobile workflow as a Maestro flow
|
|
32
|
+
forgeqe mobile validate <workflow.yaml> check a workflow without running it`
|
|
33
|
+
|
|
34
|
+
function config(flags: Flags): AppConfig {
|
|
35
|
+
const cfg = loadConfig(configOpts(flags))
|
|
36
|
+
checkMobileConfig(cfg)
|
|
37
|
+
return cfg
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function driverFlag(flags: Flags): "appium" | "maestro" | undefined {
|
|
41
|
+
const d = strFlag(flags, "driver")
|
|
42
|
+
if (d === undefined) return undefined
|
|
43
|
+
if (d !== "appium" && d !== "maestro") throw new Error("--driver must be appium or maestro")
|
|
44
|
+
return d
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cmdReplay(args: string[], flags: Flags): Promise<number> {
|
|
48
|
+
const { paths, variables } = splitReplayArgs(args)
|
|
49
|
+
if (!paths.length) throw new Error(MOBILE_USAGE)
|
|
50
|
+
const workflows = workflowFiles(paths, process.cwd(), "mobile")
|
|
51
|
+
const dataFile = strFlag(flags, "data")
|
|
52
|
+
const rows = dataFile ? parseCsv(readFileSync(path.resolve(dataFile), "utf8")) : [{}]
|
|
53
|
+
if (!rows.length) throw new Error(`${dataFile} has a header but no rows`)
|
|
54
|
+
const cfg = config(flags)
|
|
55
|
+
const driver = driverFlag(flags) || cfg.mobile.driver
|
|
56
|
+
const heal = cfg.mobile.heal && !boolFlag(flags, "no-heal")
|
|
57
|
+
if (driver === "appium" && heal && !hasModel(cfg)) console.error("Note: no model in llms.json, so moved controls will not be healed.")
|
|
58
|
+
const outDir = mobileOutDir(cfg, "replay", strFlag(flags, "out"))
|
|
59
|
+
const { signal, done } = abortOnSigint()
|
|
60
|
+
const cases: JunitCase[] = []
|
|
61
|
+
let failed = 0
|
|
62
|
+
try {
|
|
63
|
+
for (const workflow of workflows) {
|
|
64
|
+
const label = path.relative(process.cwd(), workflow) || workflow
|
|
65
|
+
if (workflows.length > 1) console.log(`\n${label}`)
|
|
66
|
+
let offered = false
|
|
67
|
+
for (let i = 0; i < rows.length; i++) {
|
|
68
|
+
if (signal.aborted) break
|
|
69
|
+
const dir = workflows.length * rows.length > 1 ? mobileOutDir(cfg, "replay", path.join(outDir, `${path.basename(workflow).replace(/\.ya?ml$/i, "")}${rows.length > 1 ? `-row-${String(i + 1).padStart(3, "0")}` : ""}`)) : outDir
|
|
70
|
+
const started = Date.now()
|
|
71
|
+
const run = await replayMobile({ cfg, workflow, variables: { ...variables, ...rows[i] }, heal, driver, updateBaselines: boolFlag(flags, "update-baselines"), outDir: dir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
|
|
72
|
+
const bad = (run.result.steps || []).find((s) => !s.ok)
|
|
73
|
+
const error = run.result.error || (bad ? `step ${bad.index} (${bad.op}): ${bad.error || "failed"}` : run.result.ok ? "" : "failed")
|
|
74
|
+
if (run.result.error) console.error(run.result.error)
|
|
75
|
+
console.log(`report ${run.report}`)
|
|
76
|
+
if (!run.result.ok) failed++
|
|
77
|
+
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 })
|
|
78
|
+
if (run.result.heals?.length && !offered) {
|
|
79
|
+
offered = true
|
|
80
|
+
await offerHeals(workflow, dir, run.result.heals, flags, signal)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
writeJunit(flags, cases)
|
|
85
|
+
const planned = workflows.length * rows.length
|
|
86
|
+
if (planned > 1) console.log(`\n${cases.length - failed} of ${planned} runs passed`)
|
|
87
|
+
return cases.length === planned && failed === 0 ? 0 : 1
|
|
88
|
+
} finally {
|
|
89
|
+
done()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeJunit(flags: Flags, cases: JunitCase[]): void {
|
|
94
|
+
const junit = strFlag(flags, "junit")
|
|
95
|
+
if (!junit) return
|
|
96
|
+
const file = path.resolve(junit)
|
|
97
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
98
|
+
writeFileSync(file, junitXml(cases, "forgeqe mobile"))
|
|
99
|
+
console.log(`junit ${file}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function cmdMaestro(args: string[], flags: Flags): Promise<number> {
|
|
103
|
+
if (!args.length) throw new Error(MOBILE_USAGE)
|
|
104
|
+
const cfg = config(flags)
|
|
105
|
+
const root = process.cwd()
|
|
106
|
+
const flows = maestroFlowFiles(args, root)
|
|
107
|
+
const outDir = mobileOutDir(cfg, "maestro", strFlag(flags, "out"))
|
|
108
|
+
const { signal, done } = abortOnSigint()
|
|
109
|
+
try {
|
|
110
|
+
const started = Date.now()
|
|
111
|
+
const { result, report } = await runMaestroFlows({ cfg, flows, root, outDir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
|
|
112
|
+
for (const f of result.flows || []) console.log(`${f.ok ? "✓" : "✗"} ${f.name}${f.error ? `: ${f.error}` : ""}`)
|
|
113
|
+
if (result.error) console.error(result.error)
|
|
114
|
+
console.log(`report ${report}`)
|
|
115
|
+
const seconds = (Date.now() - started) / 1000
|
|
116
|
+
writeJunit(flags, (result.flows || []).map((f) => ({ suite: "maestro", name: f.name, ok: f.ok, seconds: seconds / Math.max(1, (result.flows || []).length), ...(f.error ? { error: f.error } : {}), report })))
|
|
117
|
+
return result.ok ? 0 : 1
|
|
118
|
+
} finally {
|
|
119
|
+
done()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function cmdExplore(args: string[], flags: Flags): Promise<number> {
|
|
124
|
+
const goal = args.join(" ").trim()
|
|
125
|
+
const app = strFlag(flags, "app")
|
|
126
|
+
if (!goal || !app) throw new Error(MOBILE_USAGE)
|
|
127
|
+
const cfg = config(flags)
|
|
128
|
+
checkMobileApp(cfg, app)
|
|
129
|
+
if (!hasModel(cfg)) throw new Error("No model is set up in llms.json. Run: forgeqe init, then forgeqe models.")
|
|
130
|
+
const problem = visionProblem(cfg.activeProfile, cfg.llm)
|
|
131
|
+
if (problem) throw new Error(problem.replace("the desktop agent", "the mobile agent"))
|
|
132
|
+
const { signal, done } = abortOnSigint()
|
|
133
|
+
try {
|
|
134
|
+
if (!boolFlag(flags, "yes")) {
|
|
135
|
+
const ask = terminalAsk(signal)
|
|
136
|
+
if (!ask) throw new Error("explore acts in the app on a real device or emulator; pass --yes to run it without a terminal to confirm in.")
|
|
137
|
+
const answer = await ask({
|
|
138
|
+
title: `The mobile agent will operate ${app} to: ${goal}`,
|
|
139
|
+
detail: `It acts in the app through Appium (${cfg.mobile.appium.url}), and its model is ${cfg.activeProfile}. Use a test device and a test account.`,
|
|
140
|
+
options: [
|
|
141
|
+
{ id: "no", label: "Cancel" },
|
|
142
|
+
{ id: "yes", label: "Start" },
|
|
143
|
+
],
|
|
144
|
+
})
|
|
145
|
+
if (answer !== "yes") return 1
|
|
146
|
+
}
|
|
147
|
+
const outDir = mobileOutDir(cfg, "explore", strFlag(flags, "out"))
|
|
148
|
+
const onEvent = (ev: DesktopEvent) => {
|
|
149
|
+
if (ev.type === "step") console.log(`● ${String(ev.index).padStart(3)} ${ev.op}${ev.target ? ` ${String(ev.target.name || ev.target.automation_id || "")}` : ""}`)
|
|
150
|
+
else printer(color())(ev)
|
|
151
|
+
}
|
|
152
|
+
const { result, report } = await exploreMobile({ cfg, goal, app, name: strFlag(flags, "name"), outDir, signal, onEvent, log: (t) => console.error(t) })
|
|
153
|
+
if (result.error) console.error(result.error)
|
|
154
|
+
console.log(`\nreport ${report}`)
|
|
155
|
+
if (result.workflow) {
|
|
156
|
+
console.log(`workflow ${result.workflow} (${result.recorded} steps, ${result.checks ?? 0} checks)`)
|
|
157
|
+
console.log(`replay forgeqe mobile replay "${result.workflow}" (or --driver maestro)`)
|
|
158
|
+
}
|
|
159
|
+
return result.ok ? 0 : 1
|
|
160
|
+
} finally {
|
|
161
|
+
done()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function cmdFlow(args: string[], flags: Flags): Promise<number> {
|
|
166
|
+
const file = args[0]
|
|
167
|
+
if (!file) throw new Error(MOBILE_USAGE)
|
|
168
|
+
const cfg = config(flags)
|
|
169
|
+
const app = String((readFileSync(path.resolve(file), "utf8").match(/^app:\s*["']?([A-Za-z0-9_.-]+)/m) || [])[1] || "")
|
|
170
|
+
checkMobileApp(cfg, app)
|
|
171
|
+
const raw = cfg.mobile.apps[app] as { app_id?: string }
|
|
172
|
+
const python = await ensureEngine((t) => console.error(t))
|
|
173
|
+
const r = spawnSync(python, ["-m", "forgeqe_mobile", "flow", path.resolve(file), "--app-id", String(raw.app_id || "")], { env: engineEnv(), encoding: "utf8" })
|
|
174
|
+
if (r.error) throw r.error
|
|
175
|
+
process.stdout.write(r.stdout || "")
|
|
176
|
+
process.stderr.write(r.stderr || "")
|
|
177
|
+
return r.status ?? 1
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function cmdValidate(args: string[]): Promise<number> {
|
|
181
|
+
const file = args[0]
|
|
182
|
+
if (!file) throw new Error(MOBILE_USAGE)
|
|
183
|
+
const python = await ensureEngine((t) => console.error(t))
|
|
184
|
+
const r = spawnSync(python, ["-m", "forgeqe_desktop", "validate", path.resolve(file)], { env: engineEnv(), encoding: "utf8" })
|
|
185
|
+
if (r.error) throw r.error
|
|
186
|
+
process.stdout.write(r.stdout || "")
|
|
187
|
+
process.stderr.write(r.stderr || "")
|
|
188
|
+
return r.status ?? 1
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function cmdDoctor(flags: Flags): Promise<number> {
|
|
192
|
+
const cfg = config(flags)
|
|
193
|
+
const d = await mobileDoctor(cfg, (t) => console.error(t))
|
|
194
|
+
if (d.error) console.log(`engine not ready (${d.error})`)
|
|
195
|
+
console.log(`appium ${d.appium ? `answers at ${d.appiumUrl}${d.appiumVersion ? ` (Appium ${d.appiumVersion})` : ""}` : `no answer at ${d.appiumUrl}${d.appiumError ? ` (${d.appiumError})` : ""}; start it with: appium`}`)
|
|
196
|
+
console.log(`maestro ${d.maestro ? `installed (${d.maestroPath})` : "not installed (https://maestro.mobile.dev)"}`)
|
|
197
|
+
console.log(`driver ${cfg.mobile.driver} (mobile.driver; --driver on replay)`)
|
|
198
|
+
return (cfg.mobile.driver === "appium" ? d.appium : d.maestro) ? 0 : 1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cmdApps(flags: Flags): number {
|
|
202
|
+
const cfg = withEnvironment(config(flags), strFlag(flags, "env"))
|
|
203
|
+
const apps = Object.entries(cfg.mobile.apps)
|
|
204
|
+
if (!apps.length) {
|
|
205
|
+
console.log("No mobile apps. Add one to env.yaml (kind: mobile) or forgeqe.yaml:\n\nmobile:\n apps:\n courier:\n platform: android\n app_id: com.contoso.courier\n activity: .MainActivity\n secrets: [COURIER_PASSWORD]")
|
|
206
|
+
return 0
|
|
207
|
+
}
|
|
208
|
+
for (const [name, raw] of apps) {
|
|
209
|
+
const r = (raw && typeof raw === "object" ? raw : {}) as { platform?: string; app_id?: string; activity?: string; app?: string }
|
|
210
|
+
console.log(`${name.padEnd(16)} ${String(r.platform || "?")} ${String(r.app_id || "")}${r.activity ? ` ${r.activity}` : ""}${r.app ? ` (installs ${r.app})` : ""}`)
|
|
211
|
+
}
|
|
212
|
+
console.log(`\nsecrets ${cfg.mobile.secrets.join(", ") || "none"}`)
|
|
213
|
+
console.log(`driver ${cfg.mobile.driver}; appium ${cfg.mobile.appium.url}`)
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function cmdMobile(args: string[], flags: Flags): Promise<number> {
|
|
218
|
+
const [sub, ...rest] = args
|
|
219
|
+
switch (sub) {
|
|
220
|
+
case "apps":
|
|
221
|
+
return cmdApps(flags)
|
|
222
|
+
case "doctor":
|
|
223
|
+
return cmdDoctor(flags)
|
|
224
|
+
case "explore":
|
|
225
|
+
return cmdExplore(rest, flags)
|
|
226
|
+
case "replay":
|
|
227
|
+
return cmdReplay(rest, flags)
|
|
228
|
+
case "maestro":
|
|
229
|
+
return cmdMaestro(rest, flags)
|
|
230
|
+
case "flow":
|
|
231
|
+
return cmdFlow(rest, flags)
|
|
232
|
+
case "upload": {
|
|
233
|
+
if (!rest[0]) throw new Error(MOBILE_USAGE)
|
|
234
|
+
const id = await uploadToTestmu(config(flags), rest[0], { name: strFlag(flags, "name"), virtual: boolFlag(flags, "virtual") })
|
|
235
|
+
console.log(id)
|
|
236
|
+
console.error(`Put it in the mobile app's app: (env.yaml or forgeqe.yaml); ForgeQE gives it to Appium (LT:Options) and to Maestro on TestMu itself.`)
|
|
237
|
+
return 0
|
|
238
|
+
}
|
|
239
|
+
case "validate":
|
|
240
|
+
return cmdValidate(rest)
|
|
241
|
+
default:
|
|
242
|
+
console.log(MOBILE_USAGE)
|
|
243
|
+
return sub ? 2 : 0
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import type { AppConfig, MobileConfig } from "../config.ts"
|
|
6
|
+
import { runEngine, type DesktopEvent, type DesktopResult } from "../desktop/bridge.ts"
|
|
7
|
+
import { writeDesktopReport } from "../desktop/report.ts"
|
|
8
|
+
import { AZURE_IDENTITY_ENV } from "../entra.ts"
|
|
9
|
+
import { llmPayload } from "../engine/bridge.ts"
|
|
10
|
+
import { engineEnv, ensureEngine } from "../engine/setup.ts"
|
|
11
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mobile apps (Android, iOS) as the CLI (`forgeqe mobile`) and ForgeQE's agent (mobile_* tools) run them: through the
|
|
15
|
+
* mobile engine (`python -m forgeqe_mobile serve`, in the browser engine's Python), on Appium or Maestro. docs/MOBILE.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type MobileRun = { result: DesktopResult; report: string; outDir: string }
|
|
19
|
+
type Common = { cfg: AppConfig; outDir: string; signal?: AbortSignal; onEvent?: (e: DesktopEvent) => void; log?: (t: string) => void }
|
|
20
|
+
|
|
21
|
+
/** mobile.secrets that are ForgeQE's own (a model key, a tracker token): a workflow may never type those. */
|
|
22
|
+
export function privateMobileSecrets(cfg: AppConfig): string[] {
|
|
23
|
+
const own = new Set(privateEnvNames(cfg))
|
|
24
|
+
return cfg.mobile.secrets.filter((n) => own.has(n))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkMobileConfig(cfg: AppConfig): void {
|
|
28
|
+
const own = privateMobileSecrets(cfg)
|
|
29
|
+
if (own.length) throw new Error(`mobile.secrets lists ${own.join(", ")}, which ForgeQE itself uses (a model key or a service token); a workflow may not type it.`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function checkMobileApp(cfg: AppConfig, app: string): void {
|
|
33
|
+
if (!Object.prototype.hasOwnProperty.call(cfg.mobile.apps, app)) {
|
|
34
|
+
throw new Error(`App "${app}" is not a mobile app of this environment or of forgeqe.yaml mobile.apps (configured: ${Object.keys(cfg.mobile.apps).join(", ") || "none"}).`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The engine's view of mobile: config, with the cloud credentials as names only. */
|
|
39
|
+
export function mobilePayload(m: MobileConfig): Record<string, unknown> {
|
|
40
|
+
return {
|
|
41
|
+
apps: m.apps,
|
|
42
|
+
secrets: m.secrets,
|
|
43
|
+
driver: m.driver,
|
|
44
|
+
screenshots: m.screenshots,
|
|
45
|
+
appium: { url: m.appium.url, capabilities: m.appium.capabilities, ...(m.appium.usernameEnv ? { username_env: m.appium.usernameEnv } : {}), ...(m.appium.accessKeyEnv ? { access_key_env: m.appium.accessKeyEnv } : {}) },
|
|
46
|
+
maestro: {
|
|
47
|
+
command: m.maestro.command,
|
|
48
|
+
...(m.maestro.device ? { device: m.maestro.device } : {}),
|
|
49
|
+
...(m.maestro.testmu
|
|
50
|
+
? { testmu: { cli: m.maestro.testmu.cli, devices: m.maestro.testmu.devices, real_device: m.maestro.testmu.realDevice, ...(m.maestro.testmu.region ? { region: m.maestro.testmu.region } : {}), username_env: m.maestro.testmu.usernameEnv, access_key_env: m.maestro.testmu.accessKeyEnv } }
|
|
51
|
+
: {}),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What the engine process may see besides system settings: the model's key, mobile.secrets, the cloud's user and key,
|
|
57
|
+
* and the Android and Java settings Maestro and Appium need. */
|
|
58
|
+
export function mobileEnvNames(cfg: AppConfig): string[] {
|
|
59
|
+
const llm = cfg.llm
|
|
60
|
+
return [...new Set([
|
|
61
|
+
...(llm.auth === "entra" ? AZURE_IDENTITY_ENV : llm.apiKeyEnv ? [llm.apiKeyEnv] : []),
|
|
62
|
+
...cfg.mobile.secrets,
|
|
63
|
+
...[cfg.mobile.appium.usernameEnv, cfg.mobile.appium.accessKeyEnv, cfg.mobile.maestro.testmu?.usernameEnv, cfg.mobile.maestro.testmu?.accessKeyEnv].filter((n): n is string => Boolean(n)),
|
|
64
|
+
"JAVA_HOME",
|
|
65
|
+
"ANDROID_HOME",
|
|
66
|
+
"ANDROID_SDK_ROOT",
|
|
67
|
+
"MAESTRO_CLI_NO_ANALYTICS",
|
|
68
|
+
])]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stamp(): string {
|
|
72
|
+
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function mobileOutDir(cfg: AppConfig, kind: "explore" | "replay" | "maestro", out?: string): string {
|
|
76
|
+
const dir = out ? path.resolve(out) : path.join(cfg.export.outDir, `mobile-${kind}-${stamp()}`)
|
|
77
|
+
mkdirSync(dir, { recursive: true })
|
|
78
|
+
return dir
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function modelLabel(cfg: AppConfig): string {
|
|
82
|
+
return `${cfg.activeProfile} (${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model})`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function run(opts: Common, payload: Record<string, unknown>): Promise<MobileRun> {
|
|
86
|
+
const python = await ensureEngine(opts.log || (() => {}))
|
|
87
|
+
const full = { ...payload, out_dir: opts.outDir, llm: llmPayload(opts.cfg.llm), profile_name: opts.cfg.activeProfile, mobile: mobilePayload(opts.cfg.mobile) }
|
|
88
|
+
const result = await runEngine({
|
|
89
|
+
command: [python, "-m", "forgeqe_mobile", "serve"],
|
|
90
|
+
payload: full,
|
|
91
|
+
logged: { ...payload, mobile: { apps: Object.keys(opts.cfg.mobile.apps), secrets: opts.cfg.mobile.secrets, driver: opts.cfg.mobile.driver, appium: opts.cfg.mobile.appium.url } },
|
|
92
|
+
envNames: mobileEnvNames(opts.cfg),
|
|
93
|
+
outDir: opts.outDir,
|
|
94
|
+
llm: opts.cfg.llm,
|
|
95
|
+
agent: "mobile agent",
|
|
96
|
+
log: "mobile",
|
|
97
|
+
signal: opts.signal,
|
|
98
|
+
onEvent: opts.onEvent,
|
|
99
|
+
})
|
|
100
|
+
const report = writeDesktopReport(opts.outDir, result, { model: modelLabel(opts.cfg), kind: "Mobile", ...(opts.cfg.environments?.active ? { environment: opts.cfg.environments.active } : {}) })
|
|
101
|
+
if (result.heals?.length) writeFileSync(path.join(opts.outDir, "heals.json"), JSON.stringify(result.heals, null, 2))
|
|
102
|
+
return { result, report, outDir: opts.outDir }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function hasModel(cfg: AppConfig): boolean {
|
|
106
|
+
return Boolean(cfg.llm.model) && cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Replay a mobile workflow on Appium (step by step, healing a moved control) or Maestro. */
|
|
110
|
+
export function replayMobile(opts: Common & { workflow: string; variables?: Record<string, string>; heal: boolean; driver?: "appium" | "maestro"; updateBaselines?: boolean }): Promise<MobileRun> {
|
|
111
|
+
const driver = opts.driver || opts.cfg.mobile.driver
|
|
112
|
+
return run(opts, {
|
|
113
|
+
mode: "replay",
|
|
114
|
+
workflow: opts.workflow,
|
|
115
|
+
variables: opts.variables || {},
|
|
116
|
+
driver,
|
|
117
|
+
heal: driver === "appium" && opts.heal && hasModel(opts.cfg),
|
|
118
|
+
update_baselines: Boolean(opts.updateBaselines),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The model carries out a goal in the app through Appium; what it did is saved as workflow.yaml, with checks. */
|
|
123
|
+
export function exploreMobile(opts: Common & { goal: string; app: string; name?: string }): Promise<MobileRun> {
|
|
124
|
+
return run(opts, { mode: "explore", goal: opts.goal, app: opts.app, ...(opts.name ? { name: opts.name } : {}) })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The team's own Maestro flows, checked against the allowlist, then run. */
|
|
128
|
+
export function runMaestroFlows(opts: Common & { flows: string[]; root: string }): Promise<MobileRun> {
|
|
129
|
+
return run(opts, { mode: "maestro", flows: opts.flows, root: opts.root })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Maestro flow files a caller names, as real paths inside `root`: .yaml files, and the flows directly in a folder. */
|
|
133
|
+
export function maestroFlowFiles(paths: string[], root: string): string[] {
|
|
134
|
+
const realRoot = realpathSync(root)
|
|
135
|
+
const inside = (p: string) => {
|
|
136
|
+
const rel = path.relative(realRoot, p)
|
|
137
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
|
|
138
|
+
}
|
|
139
|
+
const out: string[] = []
|
|
140
|
+
for (const p of paths) {
|
|
141
|
+
const full = path.resolve(root, p)
|
|
142
|
+
if (!existsSync(full)) throw new Error(`No flow at ${full}`)
|
|
143
|
+
const real = realpathSync(full)
|
|
144
|
+
if (!inside(real) && real !== realRoot) throw new Error(`${p} is outside the working folder; Maestro flows are read from it only.`)
|
|
145
|
+
if (statSync(real).isDirectory()) {
|
|
146
|
+
const found = readdirSync(real)
|
|
147
|
+
.filter((f) => /\.ya?ml$/i.test(f) && !f.startsWith("."))
|
|
148
|
+
.sort()
|
|
149
|
+
.map((f) => path.join(real, f))
|
|
150
|
+
.filter((f) => statSync(f).isFile() && /^appId:/m.test(readFileSync(f, "utf8")))
|
|
151
|
+
if (!found.length) throw new Error(`${full} has no Maestro flows (.yaml files with appId:)`)
|
|
152
|
+
out.push(...found)
|
|
153
|
+
} else if (/\.ya?ml$/i.test(real)) out.push(real)
|
|
154
|
+
else throw new Error(`${p} is not a Maestro flow (.yaml)`)
|
|
155
|
+
}
|
|
156
|
+
return [...new Set(out)]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type MobileDoctor = { python?: string; appium: boolean; appiumUrl: string; appiumVersion?: string; appiumError?: string; maestro: boolean; maestroPath?: string; error?: string }
|
|
160
|
+
|
|
161
|
+
/** Whether the Appium server answers and maestro is installed. */
|
|
162
|
+
export async function mobileDoctor(cfg: AppConfig, log: (t: string) => void = () => {}): Promise<MobileDoctor> {
|
|
163
|
+
let python: string
|
|
164
|
+
try {
|
|
165
|
+
python = await ensureEngine(log)
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return { appium: false, appiumUrl: cfg.mobile.appium.url, maestro: false, error: (e as Error).message }
|
|
168
|
+
}
|
|
169
|
+
const r = spawnSync(python, ["-m", "forgeqe_mobile", "doctor", "--appium-url", cfg.mobile.appium.url, "--maestro", cfg.mobile.maestro.command[0]!], { env: engineEnv({}, mobileEnvNames(cfg)), encoding: "utf8" })
|
|
170
|
+
try {
|
|
171
|
+
const d = JSON.parse((r.stdout || "").trim().split(/\r?\n/).pop() || "{}") as Record<string, unknown>
|
|
172
|
+
return {
|
|
173
|
+
python,
|
|
174
|
+
appium: d.appium === true,
|
|
175
|
+
appiumUrl: cfg.mobile.appium.url,
|
|
176
|
+
...(d.appium_version ? { appiumVersion: String(d.appium_version) } : {}),
|
|
177
|
+
...(d.appium_error ? { appiumError: String(d.appium_error) } : {}),
|
|
178
|
+
maestro: d.maestro === true,
|
|
179
|
+
...(d.maestro_path ? { maestroPath: String(d.maestro_path) } : {}),
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return { python, appium: false, appiumUrl: cfg.mobile.appium.url, maestro: false, error: (r.stderr || "the check did not run").trim().split(/\r?\n/).pop() }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** TestMu AI's app upload: real devices, or its emulators and simulators. */
|
|
187
|
+
export const TESTMU_UPLOAD = {
|
|
188
|
+
real: "https://manual-api.lambdatest.com/app/upload/realDevice",
|
|
189
|
+
virtual: "https://manual-api.lambdatest.com/app/upload/virtualDevice",
|
|
190
|
+
} as const
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Upload an app build to TestMu AI and return its lt:// id, for the mobile app's `app`. The user and key come from the
|
|
194
|
+
* variables mobile.appium (or mobile.maestro.testmu) names, LT_USERNAME and LT_ACCESS_KEY by default.
|
|
195
|
+
*/
|
|
196
|
+
export async function uploadToTestmu(cfg: AppConfig, file: string, opts: { name?: string; virtual?: boolean; fetch?: typeof fetch } = {}): Promise<string> {
|
|
197
|
+
const full = path.resolve(file)
|
|
198
|
+
if (!/\.(apk|aab|ipa|zip)$/i.test(full)) throw new Error(`${file} is not an app build (.apk, .aab, .ipa or .zip).`)
|
|
199
|
+
if (!existsSync(full) || !statSync(full).isFile()) throw new Error(`No app build at ${full}`)
|
|
200
|
+
const userEnv = cfg.mobile.maestro.testmu?.usernameEnv || cfg.mobile.appium.usernameEnv || "LT_USERNAME"
|
|
201
|
+
const keyEnv = cfg.mobile.maestro.testmu?.accessKeyEnv || cfg.mobile.appium.accessKeyEnv || "LT_ACCESS_KEY"
|
|
202
|
+
const user = process.env[userEnv]
|
|
203
|
+
const key = process.env[keyEnv]
|
|
204
|
+
if (!user || !key) throw new Error(`Set ${userEnv} and ${keyEnv} (your TestMu user name and access key), for example in .env in this folder.`)
|
|
205
|
+
const form = new FormData()
|
|
206
|
+
form.append("appFile", new Blob([readFileSync(full)]), path.basename(full))
|
|
207
|
+
form.append("name", (opts.name || path.basename(full)).replace(/[^A-Za-z0-9 ._-]/g, "_").slice(0, 100))
|
|
208
|
+
const res = await (opts.fetch || fetch)(opts.virtual ? TESTMU_UPLOAD.virtual : TESTMU_UPLOAD.real, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { authorization: `Basic ${Buffer.from(`${user}:${key}`).toString("base64")}` },
|
|
211
|
+
body: form,
|
|
212
|
+
})
|
|
213
|
+
const text = await res.text()
|
|
214
|
+
let doc: { app_url?: unknown; message?: unknown; error?: unknown } = {}
|
|
215
|
+
try {
|
|
216
|
+
doc = JSON.parse(text)
|
|
217
|
+
} catch {
|
|
218
|
+
// reported below
|
|
219
|
+
}
|
|
220
|
+
const appUrl = typeof doc.app_url === "string" ? doc.app_url : ""
|
|
221
|
+
if (!res.ok || !/^lt:\/\/[A-Za-z0-9]+$/.test(appUrl)) {
|
|
222
|
+
const why = String(doc.message || doc.error || text || res.statusText).split(key).join("***").slice(0, 300)
|
|
223
|
+
throw new Error(`TestMu did not take the upload (${res.status}): ${why}`)
|
|
224
|
+
}
|
|
225
|
+
return appUrl
|
|
226
|
+
}
|
package/src/pack/pipeline.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { http } from "../cases/http.ts"
|
|
|
8
8
|
import { parseResults, type TestOutcome, type TestsRun } from "../code/find.ts"
|
|
9
9
|
import { walk } from "../code/profile.ts"
|
|
10
10
|
import { runDirectory } from "../run.ts"
|
|
11
|
+
import { environmentVariables } from "../environments.ts"
|
|
11
12
|
import { loadPack, savePack, type Pack, type PackRun } from "./pack.ts"
|
|
12
13
|
import { WS_ENDPOINT_ENV } from "./run.ts"
|
|
13
14
|
|
|
@@ -72,6 +73,28 @@ function testCommands(framework: Framework, dotnet?: { project: string; settings
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** A GitHub Actions workflow that runs a pack: dispatched by ForgeQE with the pack's selector and target. */
|
|
76
|
+
/**
|
|
77
|
+
* The test environment's FORGEQE_* values (src/environments.ts) into the job's environment, from the env_vars input:
|
|
78
|
+
* only FORGEQE_ names, and no value with a line break, so an input can never set another variable.
|
|
79
|
+
*/
|
|
80
|
+
const ENV_VARS_SCRIPT = [
|
|
81
|
+
"python3 - <<'PY' >> {OUT}",
|
|
82
|
+
"import json, os, re",
|
|
83
|
+
"for k, v in json.loads(os.environ.get('ENV_VARS') or '{}').items():",
|
|
84
|
+
" if re.fullmatch(r'FORGEQE_[A-Z0-9_]+', k) and isinstance(v, str) and not re.search(r'[\\r\\n]', v):",
|
|
85
|
+
" print({LINE})",
|
|
86
|
+
"PY",
|
|
87
|
+
].join("\n")
|
|
88
|
+
|
|
89
|
+
/** Whether a pipeline file ForgeQE wrote takes the env_vars input (files written before it do not, and reject it). */
|
|
90
|
+
export function takesEnvVars(file: string): boolean {
|
|
91
|
+
try {
|
|
92
|
+
return /\benv_vars\b/.test(readFileSync(file, "utf8"))
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
export function githubWorkflow(framework: Framework, remote: BrowserRemoteConfig | undefined, dotnet?: { project: string; settings?: string }): string {
|
|
76
99
|
const t = testCommands(framework, dotnet)
|
|
77
100
|
const setupStep = framework === "dotnet" ? ` - uses: actions/setup-dotnet@v4\n with:\n dotnet-version: "9.0.x"` : framework === "playwright" ? ` - uses: actions/setup-node@v4\n with:\n node-version: 22` : ` - uses: actions/setup-python@v5\n with:\n python-version: "3.12"`
|
|
@@ -96,6 +119,10 @@ on:
|
|
|
96
119
|
type: choice
|
|
97
120
|
options: [local, testmu]
|
|
98
121
|
default: local
|
|
122
|
+
env_vars:
|
|
123
|
+
description: The test environment's FORGEQE_* values (env.yaml), as JSON; ForgeQE fills it
|
|
124
|
+
required: false
|
|
125
|
+
default: "{}"
|
|
99
126
|
jobs:
|
|
100
127
|
pack:
|
|
101
128
|
runs-on: ubuntu-latest
|
|
@@ -115,6 +142,11 @@ ${indent(t.setup.join("\n"), 10)}
|
|
|
115
142
|
RUN_ID: \${{ inputs.run_id }}
|
|
116
143
|
run: |
|
|
117
144
|
${indent(endpointScript(remote, '"$GITHUB_ENV"'), 10)}
|
|
145
|
+
- name: Test environment
|
|
146
|
+
env:
|
|
147
|
+
ENV_VARS: \${{ inputs.env_vars }}
|
|
148
|
+
run: |
|
|
149
|
+
${indent(ENV_VARS_SCRIPT.replace("{OUT}", '"$GITHUB_ENV"').replace("{LINE}", "f\"{k}={v}\""), 10)}
|
|
118
150
|
- name: Run the pack
|
|
119
151
|
env:
|
|
120
152
|
TESTS: \${{ inputs.tests }}
|
|
@@ -150,6 +182,9 @@ parameters:
|
|
|
150
182
|
type: string
|
|
151
183
|
default: local
|
|
152
184
|
values: [local, testmu]
|
|
185
|
+
- name: env_vars
|
|
186
|
+
type: string
|
|
187
|
+
default: "{}"
|
|
153
188
|
pool:
|
|
154
189
|
vmImage: ubuntu-latest
|
|
155
190
|
steps:
|
|
@@ -168,6 +203,11 @@ ${indent(endpointScript(remote, "endpoint.env"), 6)}
|
|
|
168
203
|
LT_ACCESS_KEY: $(LT_ACCESS_KEY)
|
|
169
204
|
PACK: \${{ parameters.pack }}
|
|
170
205
|
RUN_ID: \${{ parameters.run_id }}
|
|
206
|
+
- script: |
|
|
207
|
+
${indent(ENV_VARS_SCRIPT.replace("{OUT}", "/dev/stdout").replace("{LINE}", "f\"##vso[task.setvariable variable={k}]{v}\""), 6)}
|
|
208
|
+
displayName: Test environment
|
|
209
|
+
env:
|
|
210
|
+
ENV_VARS: \${{ parameters.env_vars }}
|
|
171
211
|
- script: ${t.run}
|
|
172
212
|
displayName: Run the pack
|
|
173
213
|
env:
|
|
@@ -269,7 +309,9 @@ export async function runPackOnPipeline(opts: PipelineOpts): Promise<{ pack: Pac
|
|
|
269
309
|
if (kind === "github") {
|
|
270
310
|
const g = pipe!.github!
|
|
271
311
|
const exec = opts.exec || ghExec
|
|
272
|
-
const
|
|
312
|
+
const envVars = environmentVariables(cfg)
|
|
313
|
+
const withEnv = Object.keys(envVars).length && takesEnvVars(path.join(opts.repo, ".github", "workflows", g.workflow))
|
|
314
|
+
const inputs = { pack: pack.name, run_id: runId, tests: selector, target: opts.target, ...(withEnv ? { env_vars: JSON.stringify(envVars) } : {}) }
|
|
273
315
|
command = `gh workflow run ${g.workflow} -R ${g.repo} --ref ${g.ref} ${Object.keys(inputs).map((k) => `-f ${k}=…`).join(" ")}`
|
|
274
316
|
const fired = await exec(["workflow", "run", g.workflow, "-R", g.repo, "--ref", g.ref, ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])], opts.repo)
|
|
275
317
|
if (fired.code !== 0) throw new Error(`GitHub did not start ${g.workflow} in ${g.repo}: ${(fired.err || fired.out).trim().slice(0, 300)}`)
|
|
@@ -318,7 +360,7 @@ export async function runPackOnPipeline(opts: PipelineOpts): Promise<{ pack: Pac
|
|
|
318
360
|
command = `Azure DevOps pipeline ${a.pipelineId} in ${a.project} (${a.ref})`
|
|
319
361
|
const { data: queued } = await http<{ id: number; _links?: { web?: { href?: string } } }>(`${base}/_apis/pipelines/${a.pipelineId}/runs?api-version=7.1`, {
|
|
320
362
|
auth,
|
|
321
|
-
body: { resources: { repositories: { self: { refName: a.ref } } }, templateParameters: { pack: pack.name, run_id: runId, tests: selector, target: opts.target } },
|
|
363
|
+
body: { resources: { repositories: { self: { refName: a.ref } } }, templateParameters: { pack: pack.name, run_id: runId, tests: selector, target: opts.target, ...(Object.keys(environmentVariables(cfg)).length && takesEnvVars(path.join(opts.repo, ADO_PIPELINE_FILE)) ? { env_vars: JSON.stringify(environmentVariables(cfg)) } : {}) } },
|
|
322
364
|
})
|
|
323
365
|
url = queued?._links?.web?.href || ""
|
|
324
366
|
say(`Queued pipeline run ${queued.id}${url ? ` (${url})` : ""}`)
|
package/src/refine.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { allStatuses } from "./capabilities/status.ts"
|
|
|
11
11
|
import { domainNotes } from "./domain.ts"
|
|
12
12
|
import type { CodeEvent } from "./types.ts"
|
|
13
13
|
import { steScore } from "./ste.ts"
|
|
14
|
+
import { guardrailsNotes } from "./guardrails.ts"
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The shell's Refine (ctrl+r): what the user typed becomes a precise request for ForgeQE's agent, checked against what
|
|
@@ -151,6 +152,8 @@ export async function refine(opts: {
|
|
|
151
152
|
profile: opts.config.llm,
|
|
152
153
|
// The refiner works on the request, not the domain: ForgeQE's agent applies that when it handles it.
|
|
153
154
|
domain: null,
|
|
155
|
+
// The refined request is what the agents work to, so it must not ask for anything a guardrail forbids.
|
|
156
|
+
guardrails: guardrailsNotes(opts.config),
|
|
154
157
|
model: opts.config.code.model,
|
|
155
158
|
bin: opts.config.code.opencode,
|
|
156
159
|
readDirs: [cwd],
|
package/src/report/html.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { readTimeline, type TimelineStep } from "./data.ts"
|
|
|
14
14
|
|
|
15
15
|
/** Where and how the run happened, shown at the top of the report. */
|
|
16
16
|
export type ReportEnv = {
|
|
17
|
+
/** The test environment (env.yaml) the run used. */
|
|
18
|
+
testEnvironment?: string
|
|
17
19
|
model: string
|
|
18
20
|
browser: string
|
|
19
21
|
startUrl?: string
|
|
@@ -54,6 +56,7 @@ export function reportEnv(
|
|
|
54
56
|
? `${remote.browser} ${remote.browserVersion} on ${remote.platform || [remote.os, remote.osVersion].filter(Boolean).join(" ")} (${opts.browser!.provider}${opts.browser!.device ? `, device ${opts.browser!.device}` : ""})`
|
|
55
57
|
: undefined
|
|
56
58
|
return {
|
|
59
|
+
...(config.environments?.active ? { testEnvironment: config.environments.active } : {}),
|
|
57
60
|
model: `${config.activeProfile} (${llm.provider}/${llm.deployment || llm.model})`,
|
|
58
61
|
browser:
|
|
59
62
|
llm.provider === "mock"
|