@capagents/forgeqe 1.2.1 → 1.3.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 +39 -2
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +53 -6
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +35 -4
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +59 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +155 -3
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/loop.ts +17 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +22 -2
- package/src/suite/run.ts +12 -5
- package/src/summary.ts +1 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +89 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/types.ts +9 -2
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import type { AppConfig, BrowserRemoteConfig } from "../config.ts"
|
|
6
|
+
import { adoSettings } from "../cases/ado.ts"
|
|
7
|
+
import { http } from "../cases/http.ts"
|
|
8
|
+
import { parseResults, type TestOutcome, type TestsRun } from "../code/find.ts"
|
|
9
|
+
import { walk } from "../code/profile.ts"
|
|
10
|
+
import { runDirectory } from "../run.ts"
|
|
11
|
+
import { loadPack, savePack, type Pack, type PackRun } from "./pack.ts"
|
|
12
|
+
import { WS_ENDPOINT_ENV } from "./run.ts"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A pack in CI: a GitHub Actions workflow or an Azure DevOps pipeline in the test repository runs the pack's tests,
|
|
16
|
+
* selected by one input, on the runner's browser or on TestMu AI. The pipeline builds the TestMu AI endpoint itself
|
|
17
|
+
* from its own secrets (LT_USERNAME, LT_ACCESS_KEY): no key passes through ForgeQE. ForgeQE starts the run, waits for
|
|
18
|
+
* it, and reads each test's result (the TRX or JUnit artifact on GitHub, the Test Runs API on Azure DevOps).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type Framework = "dotnet" | "playwright" | "pytest"
|
|
22
|
+
export const WORKFLOW_FILE = "forgeqe-pack.yml"
|
|
23
|
+
export const ADO_PIPELINE_FILE = path.join("pipelines", "forgeqe-pack.yml")
|
|
24
|
+
const ARTIFACT = "forgeqe-pack-results"
|
|
25
|
+
|
|
26
|
+
/** What selects the pack's tests in the framework's own terms, as one pipeline input. */
|
|
27
|
+
export function packSelector(pack: Pick<Pack, "tests">): { framework: Framework; selector: string } {
|
|
28
|
+
const kinds = new Set(pack.tests.map((t) => (t.file.endsWith(".cs") ? "dotnet" : t.file.endsWith(".py") ? "pytest" : /\.[cm]?[jt]sx?$/.test(t.file) ? "playwright" : "other")))
|
|
29
|
+
if (kinds.size !== 1 || kinds.has("other")) throw new Error(`A pipeline runs one framework's tests; this pack has ${[...kinds].join(" and ")} tests. Run it on local or testmu.`)
|
|
30
|
+
const framework = [...kinds][0] as Framework
|
|
31
|
+
if (framework === "dotnet") return { framework, selector: pack.tests.map((t) => `FullyQualifiedName~${t.test}`).join("|") }
|
|
32
|
+
if (framework === "playwright") return { framework, selector: [...new Set(pack.tests.map((t) => t.test.split(".").pop()!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))].join("|") }
|
|
33
|
+
return { framework, selector: pack.tests.map((t) => (t.test === t.file ? t.file : `${t.file}::${t.test.replace(/\./g, "::")}`)).join(" ") }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The repository's test project and CI settings file, for the .NET pipeline. */
|
|
37
|
+
export function dotnetProject(repo: string, pack: Pick<Pack, "tests">, files = walk(repo)): { project: string; settings?: string } {
|
|
38
|
+
const projects = files.filter((f) => f.endsWith(".csproj"))
|
|
39
|
+
const project = projects.filter((p) => pack.tests.some((t) => t.file.startsWith(`${path.posix.dirname(p)}/`))).sort((a, b) => b.length - a.length)[0]
|
|
40
|
+
if (!project) throw new Error("The pack's tests are in no .NET test project.")
|
|
41
|
+
const settings = files.find((f) => /(^|\/)ci\.runsettings$/i.test(f)) || files.find((f) => f.endsWith(".runsettings"))
|
|
42
|
+
return { project, ...(settings ? { settings } : {}) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The script that sets the TestMu AI endpoint from the pipeline's secrets, with the browser from forgeqe.yaml. */
|
|
46
|
+
function endpointScript(remote: BrowserRemoteConfig | undefined, envFile: string): string {
|
|
47
|
+
const browser = remote?.browser || "Chrome"
|
|
48
|
+
const version = remote?.browserVersion || "latest"
|
|
49
|
+
const platform = remote?.platform || "Windows 11"
|
|
50
|
+
return [
|
|
51
|
+
`python3 - <<'PY' >> ${envFile}`,
|
|
52
|
+
"import json, os, urllib.parse",
|
|
53
|
+
`caps = {"browserName": ${JSON.stringify(browser)}, "browserVersion": ${JSON.stringify(version)}, "LT:Options": {"platform": ${JSON.stringify(platform)}, "build": "ForgeQE pack " + os.environ["PACK"], "name": os.environ["PACK"] + " " + os.environ["RUN_ID"], "user": os.environ["LT_USERNAME"], "accessKey": os.environ["LT_ACCESS_KEY"]}}`,
|
|
54
|
+
`print("${WS_ENDPOINT_ENV}=wss://cdp.lambdatest.com/playwright?capabilities=" + urllib.parse.quote(json.dumps(caps)))`,
|
|
55
|
+
"PY",
|
|
56
|
+
].join("\n")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const indent = (text: string, n: number) => text.split("\n").map((l) => (l ? " ".repeat(n) + l : l)).join("\n")
|
|
60
|
+
|
|
61
|
+
/** The test step's commands, reading the selector from $TESTS (never pasted into the script). */
|
|
62
|
+
function testCommands(framework: Framework, dotnet?: { project: string; settings?: string }): { setup: string[]; run: string } {
|
|
63
|
+
if (framework === "dotnet") {
|
|
64
|
+
const p = dotnet!.project
|
|
65
|
+
return {
|
|
66
|
+
setup: [`dotnet build ${p} -c Release`, `pwsh "$(find ${path.posix.dirname(p)}/bin/Release -name playwright.ps1 | head -n 1)" install --with-deps chromium`],
|
|
67
|
+
run: `dotnet test ${p} -c Release --no-build${dotnet!.settings ? ` --settings ${dotnet!.settings}` : ""} --filter "$TESTS" --logger "trx;LogFileName=pack.trx" --logger "console;verbosity=normal" --results-directory pack-results`,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (framework === "playwright") return { setup: ["npm ci", "npx playwright install --with-deps chromium"], run: 'PLAYWRIGHT_JUNIT_OUTPUT_NAME=pack-results/results.xml npx playwright test --grep "$TESTS" --reporter=line,junit' }
|
|
71
|
+
return { setup: ["python -m pip install -r requirements.txt", "python -m playwright install --with-deps chromium"], run: "python -m pytest $TESTS --junitxml=pack-results/results.xml" }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A GitHub Actions workflow that runs a pack: dispatched by ForgeQE with the pack's selector and target. */
|
|
75
|
+
export function githubWorkflow(framework: Framework, remote: BrowserRemoteConfig | undefined, dotnet?: { project: string; settings?: string }): string {
|
|
76
|
+
const t = testCommands(framework, dotnet)
|
|
77
|
+
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"`
|
|
78
|
+
return `# Written by ForgeQE (forgeqe pack pipeline init). ForgeQE dispatches it to run a regression pack.
|
|
79
|
+
# For target testmu, add the repository secrets LT_USERNAME and LT_ACCESS_KEY.
|
|
80
|
+
name: ForgeQE regression pack
|
|
81
|
+
run-name: ForgeQE pack \${{ inputs.pack }} (\${{ inputs.run_id }})
|
|
82
|
+
on:
|
|
83
|
+
workflow_dispatch:
|
|
84
|
+
inputs:
|
|
85
|
+
pack:
|
|
86
|
+
description: Pack name
|
|
87
|
+
required: true
|
|
88
|
+
run_id:
|
|
89
|
+
description: ForgeQE's id for this run
|
|
90
|
+
required: true
|
|
91
|
+
tests:
|
|
92
|
+
description: The pack's tests, in the framework's own selector
|
|
93
|
+
required: true
|
|
94
|
+
target:
|
|
95
|
+
description: Where the browsers run
|
|
96
|
+
type: choice
|
|
97
|
+
options: [local, testmu]
|
|
98
|
+
default: local
|
|
99
|
+
jobs:
|
|
100
|
+
pack:
|
|
101
|
+
runs-on: ubuntu-latest
|
|
102
|
+
timeout-minutes: 60
|
|
103
|
+
steps:
|
|
104
|
+
- uses: actions/checkout@v4
|
|
105
|
+
${setupStep}
|
|
106
|
+
- name: Prepare
|
|
107
|
+
run: |
|
|
108
|
+
${indent(t.setup.join("\n"), 10)}
|
|
109
|
+
- name: TestMu AI endpoint
|
|
110
|
+
if: inputs.target == 'testmu'
|
|
111
|
+
env:
|
|
112
|
+
LT_USERNAME: \${{ secrets.LT_USERNAME }}
|
|
113
|
+
LT_ACCESS_KEY: \${{ secrets.LT_ACCESS_KEY }}
|
|
114
|
+
PACK: \${{ inputs.pack }}
|
|
115
|
+
RUN_ID: \${{ inputs.run_id }}
|
|
116
|
+
run: |
|
|
117
|
+
${indent(endpointScript(remote, '"$GITHUB_ENV"'), 10)}
|
|
118
|
+
- name: Run the pack
|
|
119
|
+
env:
|
|
120
|
+
TESTS: \${{ inputs.tests }}
|
|
121
|
+
run: ${t.run}
|
|
122
|
+
# The results are also in the log, one line per test: a full artifact store must not fail the pack.
|
|
123
|
+
- uses: actions/upload-artifact@v4
|
|
124
|
+
if: always()
|
|
125
|
+
continue-on-error: true
|
|
126
|
+
with:
|
|
127
|
+
name: ${ARTIFACT}
|
|
128
|
+
path: pack-results
|
|
129
|
+
retention-days: 1
|
|
130
|
+
`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** An Azure DevOps pipeline that runs a pack: queued by ForgeQE with the pack's selector and target. */
|
|
134
|
+
export function adoPipeline(framework: Framework, remote: BrowserRemoteConfig | undefined, dotnet?: { project: string; settings?: string }): string {
|
|
135
|
+
const t = testCommands(framework, dotnet)
|
|
136
|
+
const tool = framework === "dotnet" ? ` - task: UseDotNet@2\n inputs:\n packageType: sdk\n version: 9.0.x` : framework === "playwright" ? ` - task: NodeTool@0\n inputs:\n versionSpec: "22.x"` : ` - task: UsePythonVersion@0\n inputs:\n versionSpec: "3.12"`
|
|
137
|
+
return `# Written by ForgeQE (forgeqe pack pipeline init). ForgeQE queues it to run a regression pack.
|
|
138
|
+
# For target testmu, add the secret pipeline variables LT_USERNAME and LT_ACCESS_KEY.
|
|
139
|
+
trigger: none
|
|
140
|
+
pr: none
|
|
141
|
+
name: ForgeQE pack \${{ parameters.pack }} (\${{ parameters.run_id }})
|
|
142
|
+
parameters:
|
|
143
|
+
- name: pack
|
|
144
|
+
type: string
|
|
145
|
+
- name: run_id
|
|
146
|
+
type: string
|
|
147
|
+
- name: tests
|
|
148
|
+
type: string
|
|
149
|
+
- name: target
|
|
150
|
+
type: string
|
|
151
|
+
default: local
|
|
152
|
+
values: [local, testmu]
|
|
153
|
+
pool:
|
|
154
|
+
vmImage: ubuntu-latest
|
|
155
|
+
steps:
|
|
156
|
+
${tool}
|
|
157
|
+
- script: |
|
|
158
|
+
${indent(t.setup.join("\n"), 6)}
|
|
159
|
+
displayName: Prepare
|
|
160
|
+
- script: |
|
|
161
|
+
${indent(endpointScript(remote, "endpoint.env"), 6)}
|
|
162
|
+
echo "##vso[task.setvariable variable=${WS_ENDPOINT_ENV};issecret=true]$(sed -n 's/^${WS_ENDPOINT_ENV}=//p' endpoint.env)"
|
|
163
|
+
rm endpoint.env
|
|
164
|
+
displayName: TestMu AI endpoint
|
|
165
|
+
condition: eq('\${{ parameters.target }}', 'testmu')
|
|
166
|
+
env:
|
|
167
|
+
LT_USERNAME: $(LT_USERNAME)
|
|
168
|
+
LT_ACCESS_KEY: $(LT_ACCESS_KEY)
|
|
169
|
+
PACK: \${{ parameters.pack }}
|
|
170
|
+
RUN_ID: \${{ parameters.run_id }}
|
|
171
|
+
- script: ${t.run}
|
|
172
|
+
displayName: Run the pack
|
|
173
|
+
env:
|
|
174
|
+
TESTS: \${{ parameters.tests }}
|
|
175
|
+
${WS_ENDPOINT_ENV}: $(${WS_ENDPOINT_ENV})
|
|
176
|
+
- task: PublishTestResults@2
|
|
177
|
+
condition: always()
|
|
178
|
+
inputs:
|
|
179
|
+
testResultsFormat: ${framework === "dotnet" ? "VSTest" : "JUnit"}
|
|
180
|
+
testResultsFiles: "pack-results/**/*.${framework === "dotnet" ? "trx" : "xml"}"
|
|
181
|
+
testRunTitle: ForgeQE pack \${{ parameters.pack }}
|
|
182
|
+
`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type Exec = (args: string[], cwd: string) => Promise<{ code: number; out: string; err: string }>
|
|
186
|
+
|
|
187
|
+
const ghExec: Exec = (args, cwd) =>
|
|
188
|
+
new Promise((resolve) => {
|
|
189
|
+
const p = spawn("gh", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
|
190
|
+
let out = ""
|
|
191
|
+
let err = ""
|
|
192
|
+
p.stdout.on("data", (d) => (out += d))
|
|
193
|
+
p.stderr.on("data", (d) => (err += d))
|
|
194
|
+
p.on("close", (code) => resolve({ code: code ?? 1, out, err }))
|
|
195
|
+
p.on("error", (e) => resolve({ code: 1, out, err: e.message }))
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const sleep = (ms: number, signal?: AbortSignal) =>
|
|
199
|
+
new Promise<void>((resolve) => {
|
|
200
|
+
const t = setTimeout(resolve, ms)
|
|
201
|
+
signal?.addEventListener("abort", () => (clearTimeout(t), resolve()), { once: true })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
function filesUnder(dir: string): string[] {
|
|
205
|
+
if (!existsSync(dir)) return []
|
|
206
|
+
return readdirSync(dir).flatMap((e) => {
|
|
207
|
+
const f = path.join(dir, e)
|
|
208
|
+
return statSync(f).isDirectory() ? filesUnder(f) : /\.(trx|xml)$/i.test(e) ? [f] : []
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Per-test results from a run's log: dotnet's console logger ("Passed Name [2 s]"), when there is no results file. */
|
|
213
|
+
export function testsFromLog(log: string): TestOutcome[] {
|
|
214
|
+
const out: TestOutcome[] = []
|
|
215
|
+
const seen = new Set<string>()
|
|
216
|
+
for (const line of log.split("\n")) {
|
|
217
|
+
// " Passed text#1 Wikipedia: search finds … [7 s]": the duration at the end tells it from the summary line.
|
|
218
|
+
const m = /(?:^|\s)(Passed|Failed|Skipped)\s+(.+?)\s+\[(?:< ?)?([\d.]+)\s*(ms|s|m)\]\s*$/.exec(line.replace(/\x1b\[[0-9;]*m/g, ""))
|
|
219
|
+
if (!m) continue
|
|
220
|
+
const name = m[2]!.trim()
|
|
221
|
+
if (seen.has(name)) continue
|
|
222
|
+
seen.add(name)
|
|
223
|
+
const secs = m[4] === "ms" ? Number(m[3]) / 1000 : m[4] === "m" ? Number(m[3]) * 60 : Number(m[3])
|
|
224
|
+
out.push({ name, status: m[1] === "Passed" ? "passed" : m[1] === "Failed" ? "failed" : "skipped", seconds: Math.round(secs) })
|
|
225
|
+
}
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function runOf(command: string, tests: TestOutcome[], status: TestsRun["status"], seconds: number, dir: string, output: string): TestsRun {
|
|
230
|
+
const count = (s: TestOutcome["status"]) => tests.filter((t) => t.status === s).length
|
|
231
|
+
return { status, command, exitCode: status === "passed" ? 0 : 1, seconds, tests, counts: { passed: count("passed"), failed: count("failed"), skipped: count("skipped") }, resultFiles: filesUnder(dir), browsers: [], output, outputFile: "", dir }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type PipelineOpts = {
|
|
235
|
+
config: AppConfig
|
|
236
|
+
repo: string
|
|
237
|
+
name: string
|
|
238
|
+
kind?: "github" | "azure_devops"
|
|
239
|
+
target: "local" | "testmu"
|
|
240
|
+
signal?: AbortSignal
|
|
241
|
+
exec?: Exec
|
|
242
|
+
pollMs?: number
|
|
243
|
+
onProgress?: (text: string) => void
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Starts the pack in a pipeline, waits for it, reads each test's result, and keeps the run with the pack. */
|
|
247
|
+
export async function runPackOnPipeline(opts: PipelineOpts): Promise<{ pack: Pack; run: TestsRun; record: PackRun; notes: string[] }> {
|
|
248
|
+
const cfg = opts.config
|
|
249
|
+
const pipe = cfg.packs?.pipeline
|
|
250
|
+
const kind = opts.kind || pipe?.default || (pipe?.github ? "github" : pipe?.azureDevops ? "azure_devops" : undefined)
|
|
251
|
+
if (!kind || (kind === "github" && !pipe?.github) || (kind === "azure_devops" && !pipe?.azureDevops)) {
|
|
252
|
+
throw new Error("No pipeline for packs: set packs.pipeline.github (repo, workflow) or packs.pipeline.azure_devops (project, pipeline_id) in forgeqe.yaml, after forgeqe pack pipeline init writes the pipeline file.")
|
|
253
|
+
}
|
|
254
|
+
const pack = loadPack(opts.repo, opts.name)
|
|
255
|
+
if (!pack.tests.length) throw new Error(`Pack ${pack.name} has no tests to run.`)
|
|
256
|
+
const { selector } = packSelector(pack)
|
|
257
|
+
const dir = runDirectory(path.join(cfg.export.outDir, "packs", pack.name))
|
|
258
|
+
const runId = path.basename(dir)
|
|
259
|
+
const started = Date.now()
|
|
260
|
+
const deadline = started + (pipe!.timeoutMinutes || 60) * 60_000
|
|
261
|
+
const poll = opts.pollMs ?? 15_000
|
|
262
|
+
const say = opts.onProgress || (() => {})
|
|
263
|
+
const notes: string[] = []
|
|
264
|
+
let tests: TestOutcome[] = []
|
|
265
|
+
let status: TestsRun["status"] = "failed"
|
|
266
|
+
let url = ""
|
|
267
|
+
let command = ""
|
|
268
|
+
|
|
269
|
+
if (kind === "github") {
|
|
270
|
+
const g = pipe!.github!
|
|
271
|
+
const exec = opts.exec || ghExec
|
|
272
|
+
const inputs = { pack: pack.name, run_id: runId, tests: selector, target: opts.target }
|
|
273
|
+
command = `gh workflow run ${g.workflow} -R ${g.repo} --ref ${g.ref} ${Object.keys(inputs).map((k) => `-f ${k}=…`).join(" ")}`
|
|
274
|
+
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
|
+
if (fired.code !== 0) throw new Error(`GitHub did not start ${g.workflow} in ${g.repo}: ${(fired.err || fired.out).trim().slice(0, 300)}`)
|
|
276
|
+
say(`Started ${g.workflow} in ${g.repo}; waiting for the run`)
|
|
277
|
+
let id = ""
|
|
278
|
+
while (!id && Date.now() < deadline && !opts.signal?.aborted) {
|
|
279
|
+
await sleep(Math.min(poll, 5000), opts.signal)
|
|
280
|
+
const list = await exec(["run", "list", "-R", g.repo, "-w", g.workflow, "--event", "workflow_dispatch", "--limit", "20", "--json", "databaseId,displayTitle,url"], opts.repo)
|
|
281
|
+
const hit = (JSON.parse(list.out || "[]") as { databaseId: number; displayTitle: string; url: string }[]).find((r) => r.displayTitle.includes(runId))
|
|
282
|
+
if (hit) {
|
|
283
|
+
id = String(hit.databaseId)
|
|
284
|
+
url = hit.url
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!id) throw new Error(`The run of ${g.workflow} for ${runId} did not appear in ${g.repo}.`)
|
|
288
|
+
say(`Run ${url}`)
|
|
289
|
+
let conclusion = ""
|
|
290
|
+
while (Date.now() < deadline && !opts.signal?.aborted) {
|
|
291
|
+
const view = await exec(["run", "view", id, "-R", g.repo, "--json", "status,conclusion"], opts.repo)
|
|
292
|
+
const v = JSON.parse(view.out || "{}") as { status?: string; conclusion?: string }
|
|
293
|
+
if (v.status === "completed") {
|
|
294
|
+
conclusion = v.conclusion || ""
|
|
295
|
+
break
|
|
296
|
+
}
|
|
297
|
+
await sleep(poll, opts.signal)
|
|
298
|
+
}
|
|
299
|
+
if (opts.signal?.aborted) status = "stopped"
|
|
300
|
+
else if (!conclusion) status = "timed out"
|
|
301
|
+
else {
|
|
302
|
+
mkdirSync(path.join(dir, "results"), { recursive: true })
|
|
303
|
+
const dl = await exec(["run", "download", id, "-R", g.repo, "-n", ARTIFACT, "-D", path.join(dir, "results")], opts.repo)
|
|
304
|
+
if (dl.code !== 0) notes.push(`The results artifact could not be downloaded: ${(dl.err || dl.out).trim().slice(0, 200)}`)
|
|
305
|
+
for (const f of filesUnder(path.join(dir, "results"))) tests.push(...parseResults(readFileSync(f, "utf8")))
|
|
306
|
+
if (!tests.length) {
|
|
307
|
+
// No artifact (a full artifact store, an upload that failed): the test step's log has a line per test.
|
|
308
|
+
const log = await exec(["run", "view", id, "-R", g.repo, "--log"], opts.repo)
|
|
309
|
+
tests = testsFromLog(log.out)
|
|
310
|
+
if (tests.length) notes.push("The results artifact was missing; the results come from the run's log.")
|
|
311
|
+
}
|
|
312
|
+
status = conclusion === "cancelled" ? "stopped" : tests.length ? (tests.some((t) => t.status === "failed") ? "failed" : "passed") : conclusion === "success" ? "passed" : "failed"
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
const a = pipe!.azureDevops!
|
|
316
|
+
const { org, auth } = adoSettings(cfg.sources)
|
|
317
|
+
const base = `${org}/${encodeURIComponent(a.project)}`
|
|
318
|
+
command = `Azure DevOps pipeline ${a.pipelineId} in ${a.project} (${a.ref})`
|
|
319
|
+
const { data: queued } = await http<{ id: number; _links?: { web?: { href?: string } } }>(`${base}/_apis/pipelines/${a.pipelineId}/runs?api-version=7.1`, {
|
|
320
|
+
auth,
|
|
321
|
+
body: { resources: { repositories: { self: { refName: a.ref } } }, templateParameters: { pack: pack.name, run_id: runId, tests: selector, target: opts.target } },
|
|
322
|
+
})
|
|
323
|
+
url = queued?._links?.web?.href || ""
|
|
324
|
+
say(`Queued pipeline run ${queued.id}${url ? ` (${url})` : ""}`)
|
|
325
|
+
let result = ""
|
|
326
|
+
let misses = 0
|
|
327
|
+
while (Date.now() < deadline && !opts.signal?.aborted) {
|
|
328
|
+
let data: { state?: string; result?: string } | undefined
|
|
329
|
+
try {
|
|
330
|
+
data = (await http<{ state?: string; result?: string }>(`${base}/_apis/pipelines/${a.pipelineId}/runs/${queued.id}?api-version=7.1`, { auth })).data
|
|
331
|
+
misses = 0
|
|
332
|
+
} catch (err) {
|
|
333
|
+
// A dropped connection while waiting is not the run's result: try again, a few times.
|
|
334
|
+
if (++misses > 5) throw err
|
|
335
|
+
await sleep(poll, opts.signal)
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
if (data?.state === "completed") {
|
|
339
|
+
result = data.result || ""
|
|
340
|
+
break
|
|
341
|
+
}
|
|
342
|
+
await sleep(poll, opts.signal)
|
|
343
|
+
}
|
|
344
|
+
if (opts.signal?.aborted) status = "stopped"
|
|
345
|
+
else if (!result) status = "timed out"
|
|
346
|
+
else {
|
|
347
|
+
const { data: runs } = await http<{ value?: { id: number }[] }>(`${base}/_apis/test/runs?buildUri=${encodeURIComponent(`vstfs:///Build/Build/${queued.id}`)}&api-version=7.1`, { auth })
|
|
348
|
+
for (const r of runs?.value || []) {
|
|
349
|
+
const { data: res } = await http<{ value?: { automatedTestName?: string; testCaseTitle?: string; outcome?: string; errorMessage?: string; durationInMs?: number }[] }>(`${base}/_apis/test/Runs/${r.id}/results?$top=1000&api-version=7.1`, { auth })
|
|
350
|
+
for (const t of res?.value || []) {
|
|
351
|
+
const o = String(t.outcome || "").toLowerCase()
|
|
352
|
+
tests.push({ name: t.automatedTestName || t.testCaseTitle || "", status: o === "passed" ? "passed" : o === "failed" ? "failed" : "skipped", ...(t.errorMessage ? { message: t.errorMessage } : {}), ...(t.durationInMs ? { seconds: Math.round(t.durationInMs / 1000) } : {}) } as TestOutcome)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
status = result === "canceled" ? "stopped" : tests.length ? (tests.some((t) => t.status === "failed") ? "failed" : "passed") : result === "succeeded" ? "passed" : "failed"
|
|
356
|
+
if (!tests.length && result !== "succeeded") {
|
|
357
|
+
// Why it failed before any test ran: the run's own errors (no agent, a YAML error, a build that broke).
|
|
358
|
+
try {
|
|
359
|
+
const { data: tl } = await http<{ records?: { issues?: { type?: string; message?: string }[] }[] }>(`${base}/_apis/build/builds/${queued.id}/timeline?api-version=7.1`, { auth })
|
|
360
|
+
const errors = [...new Set((tl?.records || []).flatMap((r) => (r.issues || []).filter((i) => i.type === "error").map((i) => String(i.message || "").trim())))].filter(Boolean)
|
|
361
|
+
if (errors.length) notes.push(`The pipeline failed before reporting tests: ${errors.slice(0, 3).join(" | ").slice(0, 600)}`)
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (status === "passed" && tests.some((t) => t.status === "failed")) status = "failed"
|
|
367
|
+
if (!tests.length && status !== "stopped" && status !== "timed out") notes.push("The pipeline finished but gave no per-test results.")
|
|
368
|
+
const run = runOf(command, tests, status, Math.round((Date.now() - started) / 1000), dir, url ? `Pipeline run: ${url}` : "")
|
|
369
|
+
const record: PackRun = { at: new Date().toISOString(), on: "pipeline", target: `${kind === "github" ? "GitHub Actions" : "Azure DevOps"}${opts.target === "testmu" ? " on TestMu AI" : ""}`, status, counts: run.counts, seconds: run.seconds, failed: tests.filter((t) => t.status === "failed").map((t) => t.name), dir, ...(url ? { url } : {}) }
|
|
370
|
+
pack.runs = [...pack.runs, record].slice(-20)
|
|
371
|
+
savePack(pack)
|
|
372
|
+
return { pack, run, record, notes }
|
|
373
|
+
}
|
package/src/pack/run.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { BrowserRemoteConfig, AppConfig } from "../config.ts"
|
|
5
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
6
|
+
import { runSelection, type TestSelection, type TestsRun } from "../code/find.ts"
|
|
7
|
+
import { writtenTests } from "../code/notes.ts"
|
|
8
|
+
import { walk } from "../code/profile.ts"
|
|
9
|
+
import type { runTestCommand } from "../code/testrun.ts"
|
|
10
|
+
import { runDirectory } from "../run.ts"
|
|
11
|
+
import { loadPack, packCommand, savePack, type Pack, type PackRun } from "./pack.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs a pack's tests where the release needs them: here (local), on TestMu AI's browsers (the repository's own tests,
|
|
15
|
+
* connected to a remote browser through FORGEQE_PW_WS_ENDPOINT), or in a pipeline (pipeline.ts). Each run is kept with
|
|
16
|
+
* the pack, so the next run of the same pack can be compared with it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The variable a repository's tests read to connect to a remote browser instead of launching one. */
|
|
20
|
+
export const WS_ENDPOINT_ENV = "FORGEQE_PW_WS_ENDPOINT"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* TestMu AI's (LambdaTest's) Playwright endpoint for a run: its capabilities from forgeqe.yaml (browser.remote for
|
|
24
|
+
* testmu), the build and test names, and the account from the environment. `secret` is the access key, for redacting.
|
|
25
|
+
*/
|
|
26
|
+
export function testmuEndpoint(remote: BrowserRemoteConfig | undefined, labels: { build: string; name: string }, env: Record<string, string | undefined> = process.env): { endpoint: string; secret: string; describe: string } {
|
|
27
|
+
if (!remote) throw new Error("No TestMu AI settings: add browser.remote for testmu in forgeqe.yaml (or a testmu.yaml), with usernameEnv and accessKeyEnv.")
|
|
28
|
+
const user = env[remote.usernameEnv]
|
|
29
|
+
const key = env[remote.accessKeyEnv]
|
|
30
|
+
if (!user || !key) throw new Error(`Set ${remote.usernameEnv} and ${remote.accessKeyEnv} to run on TestMu AI.`)
|
|
31
|
+
const browser = /^pw-/.test(remote.browser) ? remote.browser : remote.browser || "Chrome"
|
|
32
|
+
const caps = {
|
|
33
|
+
browserName: browser,
|
|
34
|
+
browserVersion: remote.browserVersion || "latest",
|
|
35
|
+
"LT:Options": {
|
|
36
|
+
platform: remote.platform || [remote.os, remote.osVersion].filter(Boolean).join(" ") || "Windows 11",
|
|
37
|
+
build: labels.build,
|
|
38
|
+
name: labels.name,
|
|
39
|
+
...(remote.project ? { project: remote.project } : {}),
|
|
40
|
+
user,
|
|
41
|
+
accessKey: key,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
endpoint: `wss://cdp.lambdatest.com/playwright?capabilities=${encodeURIComponent(JSON.stringify(caps))}`,
|
|
46
|
+
secret: key,
|
|
47
|
+
describe: `${browser} ${caps.browserVersion} on ${caps["LT:Options"].platform}`,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The pack as a selection run_tests' runner can run: its command, and where its results land. */
|
|
52
|
+
export function packSelection(cfg: AppConfig, pack: Pack, dir: string): { selection: TestSelection; notes: string[] } {
|
|
53
|
+
const resultsDir = path.join(dir, "results")
|
|
54
|
+
const known = cfg.memory.enabled ? writtenTests(cfg.memory.dir, pack.repo).map((t) => t.command) : []
|
|
55
|
+
const cmd = packCommand(pack.repo, pack.tests, resultsDir, known)
|
|
56
|
+
if (!cmd) throw new Error(`ForgeQE cannot build one command for the tests of pack ${pack.name} (${[...new Set(pack.tests.map((t) => path.extname(t.file)))].join(", ")} files).`)
|
|
57
|
+
return {
|
|
58
|
+
notes: cmd.notes,
|
|
59
|
+
selection: {
|
|
60
|
+
id: path.basename(dir),
|
|
61
|
+
dir,
|
|
62
|
+
repo: pack.repo,
|
|
63
|
+
request: `regression pack ${pack.name}`,
|
|
64
|
+
framework: cmd.framework,
|
|
65
|
+
tests: pack.tests.map((t) => ({ name: t.test, file: t.file, reason: t.reasons.map((r) => r.text).join("; ") })),
|
|
66
|
+
related: [],
|
|
67
|
+
listCommand: "",
|
|
68
|
+
listed: false,
|
|
69
|
+
runCommand: cmd.command,
|
|
70
|
+
results: [resultsDir],
|
|
71
|
+
notes: "",
|
|
72
|
+
model: "",
|
|
73
|
+
usage: { input: 0, output: 0 },
|
|
74
|
+
createdAt: new Date().toISOString(),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How a repository's tests connect to a remote browser when ForgeQE gives them one, by framework. */
|
|
80
|
+
export const REMOTE_HOWTO = [
|
|
81
|
+
`Make the test base class connect to the endpoint in ${WS_ENDPOINT_ENV} when it is set, and launch a browser as before when it is not.`,
|
|
82
|
+
`NUnit (Microsoft.Playwright.NUnit 1.46+): override ConnectOptionsAsync to return (endpoint, new BrowserTypeConnectOptions()) when the variable is set, else null.`,
|
|
83
|
+
`Playwright Test: in playwright.config, use: { connectOptions: process.env.${WS_ENDPOINT_ENV} ? { wsEndpoint: process.env.${WS_ENDPOINT_ENV} } : undefined }.`,
|
|
84
|
+
`pytest-playwright: a browser fixture that calls playwright.chromium.connect(os.environ["${WS_ENDPOINT_ENV}"]) when it is set.`,
|
|
85
|
+
].join(" ")
|
|
86
|
+
|
|
87
|
+
/** Whether the repository's test code reads the remote endpoint variable. */
|
|
88
|
+
export function readsEndpoint(repo: string, files = walk(repo)): boolean {
|
|
89
|
+
return files
|
|
90
|
+
.filter((f) => /\.(cs|[cm]?[jt]sx?|py|java|kt)$/.test(f))
|
|
91
|
+
.some((f) => {
|
|
92
|
+
try {
|
|
93
|
+
return readFileSync(path.join(repo, f), "utf8").includes(WS_ENDPOINT_ENV)
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const redact = (text: string, secrets: string[]) => secrets.filter((s) => s.length >= 6).reduce((t, s) => t.split(s).join("***").split(encodeURIComponent(s)).join("***"), text)
|
|
101
|
+
|
|
102
|
+
/** Runs a pack here or on TestMu AI, and keeps the run with the pack. */
|
|
103
|
+
export async function runPack(opts: {
|
|
104
|
+
config: AppConfig
|
|
105
|
+
repo: string
|
|
106
|
+
name: string
|
|
107
|
+
on: "local" | "testmu"
|
|
108
|
+
signal?: AbortSignal
|
|
109
|
+
runTest?: typeof runTestCommand
|
|
110
|
+
env?: Record<string, string | undefined>
|
|
111
|
+
}): Promise<{ pack: Pack; run: TestsRun; record: PackRun; notes: string[] }> {
|
|
112
|
+
const cfg = opts.config
|
|
113
|
+
const pack = loadPack(opts.repo, opts.name)
|
|
114
|
+
if (!pack.tests.length) throw new Error(`Pack ${pack.name} has no tests to run.`)
|
|
115
|
+
const dir = runDirectory(path.join(cfg.export.outDir, "packs", pack.name))
|
|
116
|
+
const { selection, notes } = packSelection(cfg, pack, dir)
|
|
117
|
+
let env: Record<string, string> | undefined
|
|
118
|
+
let secrets: string[] = []
|
|
119
|
+
let target = "this machine"
|
|
120
|
+
if (opts.on === "testmu") {
|
|
121
|
+
// Without it the tests would launch a local browser and the run would only look remote.
|
|
122
|
+
if (!readsEndpoint(pack.repo)) throw new Error(`The tests in ${pack.repo} do not read ${WS_ENDPOINT_ENV}, so they cannot connect to TestMu AI's browsers yet. ${REMOTE_HOWTO}`)
|
|
123
|
+
const t = testmuEndpoint(cfg.browser.remotes?.testmu, { build: `ForgeQE pack ${pack.name}`, name: `${pack.name} ${path.basename(dir)}` }, opts.env || process.env)
|
|
124
|
+
env = { [WS_ENDPOINT_ENV]: t.endpoint }
|
|
125
|
+
secrets = [t.secret]
|
|
126
|
+
target = `TestMu AI (${t.describe})`
|
|
127
|
+
}
|
|
128
|
+
const run = await runSelection({
|
|
129
|
+
selection,
|
|
130
|
+
timeoutSeconds: Math.max(cfg.code.testTimeoutSeconds, 300) * Math.max(1, Math.ceil(pack.tests.length / 10)),
|
|
131
|
+
signal: opts.signal,
|
|
132
|
+
runTest: opts.runTest,
|
|
133
|
+
hide: privateEnvNames(cfg),
|
|
134
|
+
flaky: cfg.flaky,
|
|
135
|
+
...(env ? { env } : {}),
|
|
136
|
+
})
|
|
137
|
+
if (secrets.length) {
|
|
138
|
+
run.output = redact(run.output, secrets)
|
|
139
|
+
run.command = redact(run.command, secrets)
|
|
140
|
+
if (run.outputFile && existsSync(run.outputFile)) writeFileSync(run.outputFile, redact(readFileSync(run.outputFile, "utf8"), secrets))
|
|
141
|
+
}
|
|
142
|
+
const record: PackRun = {
|
|
143
|
+
at: new Date().toISOString(),
|
|
144
|
+
on: opts.on,
|
|
145
|
+
target,
|
|
146
|
+
status: run.status,
|
|
147
|
+
counts: run.counts,
|
|
148
|
+
seconds: run.seconds,
|
|
149
|
+
failed: run.tests.filter((t) => t.status === "failed").map((t) => t.name),
|
|
150
|
+
dir,
|
|
151
|
+
}
|
|
152
|
+
pack.runs = [...pack.runs, record].slice(-20)
|
|
153
|
+
savePack(pack)
|
|
154
|
+
return { pack, run, record, notes }
|
|
155
|
+
}
|