@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.
Files changed (65) hide show
  1. package/HOW_TO_USE.md +39 -2
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +53 -6
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +35 -4
  9. package/prompts/browser-checks.md +2 -0
  10. package/py/forgeqe_engine/checks.py +8 -2
  11. package/py/forgeqe_engine/llm.py +33 -5
  12. package/py/forgeqe_engine/memory.py +59 -2
  13. package/py/forgeqe_engine/observe.py +123 -0
  14. package/py/forgeqe_engine/replay.py +55 -2
  15. package/py/forgeqe_engine/service.py +155 -3
  16. package/src/capabilities/registry.ts +19 -5
  17. package/src/cases/ado.ts +1 -1
  18. package/src/cases/journey.ts +61 -0
  19. package/src/cases/readiness.ts +35 -6
  20. package/src/cases/save.ts +241 -48
  21. package/src/cases/types.ts +11 -0
  22. package/src/cli/help.ts +8 -0
  23. package/src/cli/index.ts +64 -1
  24. package/src/code/coverage.ts +22 -3
  25. package/src/code/decisions.ts +73 -0
  26. package/src/code/find.ts +3 -1
  27. package/src/code/graph/graph.ts +33 -2
  28. package/src/code/loop.ts +17 -1
  29. package/src/code/notes.ts +48 -1
  30. package/src/code/prompt.ts +31 -9
  31. package/src/code/protect.ts +103 -0
  32. package/src/code/review.ts +2 -0
  33. package/src/code/runtime.ts +35 -3
  34. package/src/code/shortlist.ts +309 -0
  35. package/src/code/testrun.ts +3 -1
  36. package/src/code/traceability.ts +2 -0
  37. package/src/commands.ts +6 -1
  38. package/src/config.ts +46 -0
  39. package/src/engine/bridge.ts +47 -5
  40. package/src/mcp/casetools.ts +107 -11
  41. package/src/mcp/commands.ts +74 -20
  42. package/src/mcp/packtools.ts +187 -0
  43. package/src/mcp/tools.ts +238 -29
  44. package/src/pack/collect.ts +251 -0
  45. package/src/pack/index.ts +134 -0
  46. package/src/pack/init.ts +45 -0
  47. package/src/pack/pack.ts +327 -0
  48. package/src/pack/pipeline.ts +373 -0
  49. package/src/pack/run.ts +155 -0
  50. package/src/registry/db.ts +249 -0
  51. package/src/registry/index.ts +72 -0
  52. package/src/registry/resolve.ts +57 -0
  53. package/src/registry/tier0.ts +81 -0
  54. package/src/report/data.ts +7 -0
  55. package/src/report/html.ts +14 -0
  56. package/src/report/template.html +52 -48
  57. package/src/report/usage.ts +113 -0
  58. package/src/run.ts +22 -2
  59. package/src/suite/run.ts +12 -5
  60. package/src/summary.ts +1 -0
  61. package/src/tokenomics/budget.ts +172 -0
  62. package/src/tokenomics/ledger.ts +287 -0
  63. package/src/tui/App.tsx +89 -2
  64. package/src/tui/inspector.tsx +40 -35
  65. 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
+ }
@@ -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
+ }