@capagents/forgeqe 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { SourcesConfig } from "../config.ts"
|
|
2
|
+
import { adfToText, basic, envSecret, http, textToAdf } from "./http.ts"
|
|
3
|
+
import { stepVerdictLines, type CaseResult, type TestCase, type WriteBack } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
export type JiraIssue = {
|
|
6
|
+
id: string
|
|
7
|
+
key: string
|
|
8
|
+
fields: {
|
|
9
|
+
summary?: string
|
|
10
|
+
description?: unknown
|
|
11
|
+
labels?: string[]
|
|
12
|
+
components?: { name?: string }[]
|
|
13
|
+
parent?: { key?: string; fields?: { summary?: string } }
|
|
14
|
+
issuelinks?: { type?: { outward?: string }; outwardIssue?: { key?: string } }[]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type JiraApi = { url: string; cloud: boolean; auth: string; api: string }
|
|
19
|
+
|
|
20
|
+
export function jiraApi(cfg: SourcesConfig, what = "Jira"): JiraApi {
|
|
21
|
+
const j = cfg.jira
|
|
22
|
+
if (!j) throw new Error(`${what} needs sources.jira.url in forgeqe.yaml, or JIRA_URL in the environment.`)
|
|
23
|
+
const token = envSecret(j.tokenEnv, what)
|
|
24
|
+
const auth = j.cloud ? basic(envSecret(j.emailEnv, `${what} (Jira Cloud)`), token) : `Bearer ${token}`
|
|
25
|
+
return { url: j.url, cloud: j.cloud, auth, api: `${j.url}/rest/api/${j.cloud ? 3 : 2}` }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FIELDS = "summary,description,labels,components,parent,issuelinks"
|
|
29
|
+
|
|
30
|
+
/** Issues by key (`SHOP-1,SHOP-2`) or by JQL (`jql=project = SHOP AND labels = e2e`). */
|
|
31
|
+
export async function jiraIssues(ref: string, j: JiraApi): Promise<JiraIssue[]> {
|
|
32
|
+
const jql = ref.match(/^jql=(.+)$/s)?.[1]
|
|
33
|
+
if (!jql) {
|
|
34
|
+
const keys = ref.split(/[,\s]+/).map((k) => k.trim().toUpperCase()).filter(Boolean)
|
|
35
|
+
if (!keys.length) throw new Error(`Jira reference "${ref}": use jira:SHOP-1,SHOP-2 or jira:jql=<query>.`)
|
|
36
|
+
const out: JiraIssue[] = []
|
|
37
|
+
for (const key of keys) out.push((await http<JiraIssue>(`${j.api}/issue/${encodeURIComponent(key)}?fields=${FIELDS}`, { auth: j.auth })).data)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
const out: JiraIssue[] = []
|
|
41
|
+
if (j.cloud) {
|
|
42
|
+
let next: string | undefined
|
|
43
|
+
do {
|
|
44
|
+
const q = new URLSearchParams({ jql, fields: FIELDS, maxResults: "100", ...(next ? { nextPageToken: next } : {}) })
|
|
45
|
+
const { data } = await http<{ issues?: JiraIssue[]; nextPageToken?: string; isLast?: boolean }>(`${j.api}/search/jql?${q}`, { auth: j.auth })
|
|
46
|
+
out.push(...(data?.issues || []))
|
|
47
|
+
next = data?.isLast === false || data?.nextPageToken ? data?.nextPageToken : undefined
|
|
48
|
+
} while (next)
|
|
49
|
+
} else {
|
|
50
|
+
let startAt = 0
|
|
51
|
+
for (;;) {
|
|
52
|
+
const q = new URLSearchParams({ jql, fields: FIELDS, maxResults: "100", startAt: String(startAt) })
|
|
53
|
+
const { data } = await http<{ issues?: JiraIssue[]; total?: number }>(`${j.api}/search?${q}`, { auth: j.auth })
|
|
54
|
+
const page = data?.issues || []
|
|
55
|
+
out.push(...page)
|
|
56
|
+
startAt += page.length
|
|
57
|
+
if (!page.length || startAt >= Number(data?.total || 0)) break
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!out.length) throw new Error(`No Jira issues match: ${jql}`)
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A Jira issue as a test case: summary is the title, the description holds the steps and expected outcome. */
|
|
65
|
+
function issueGroup(issue: JiraIssue): string | undefined {
|
|
66
|
+
const components = (issue.fields.components || []).map((c) => c.name).filter(Boolean)
|
|
67
|
+
if (components.length) return components.join(", ")
|
|
68
|
+
const p = issue.fields.parent
|
|
69
|
+
return p?.key ? `${p.key}${p.fields?.summary ? ` ${p.fields.summary}` : ""}` : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Issues this one "tests", through Jira's Test link type (outward "tests"). */
|
|
73
|
+
function testedIssues(issue: JiraIssue): string[] | undefined {
|
|
74
|
+
const keys = (issue.fields.issuelinks || [])
|
|
75
|
+
.filter((l) => l.outwardIssue?.key && /^tests$/i.test((l.type?.outward || "").trim()))
|
|
76
|
+
.map((l) => l.outwardIssue!.key!)
|
|
77
|
+
return keys.length ? keys : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function jiraCases(ref: string, cfg: SourcesConfig): Promise<TestCase[]> {
|
|
81
|
+
const j = jiraApi(cfg)
|
|
82
|
+
return (await jiraIssues(ref, j)).map((issue) => ({
|
|
83
|
+
id: issue.key,
|
|
84
|
+
title: issue.fields.summary || issue.key,
|
|
85
|
+
source: "jira" as const,
|
|
86
|
+
url: `${j.url}/browse/${issue.key}`,
|
|
87
|
+
description: adfToText(issue.fields.description) || undefined,
|
|
88
|
+
steps: [],
|
|
89
|
+
tags: issue.fields.labels?.length ? issue.fields.labels : undefined,
|
|
90
|
+
group: issueGroup(issue),
|
|
91
|
+
requirements: testedIssues(issue),
|
|
92
|
+
ref: { issue: issue.key },
|
|
93
|
+
}))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function jiraWriteBack(results: CaseResult[], cfg: SourcesConfig): Promise<WriteBack[]> {
|
|
97
|
+
const j = jiraApi(cfg)
|
|
98
|
+
const out: WriteBack[] = []
|
|
99
|
+
for (const r of results) {
|
|
100
|
+
const key = String(r.case.ref?.issue || r.case.id)
|
|
101
|
+
const text = resultText(r)
|
|
102
|
+
try {
|
|
103
|
+
await http(`${j.api}/issue/${encodeURIComponent(key)}/comment`, { auth: j.auth, body: { body: j.cloud ? textToAdf(text) : text } })
|
|
104
|
+
out.push({ source: "jira", caseId: r.case.id, text: `${key}: result added as a comment`, url: r.case.url })
|
|
105
|
+
} catch (err) {
|
|
106
|
+
out.push({ source: "jira", caseId: r.case.id, text: `${key}: comment not added`, error: (err as Error).message })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resultText(r: CaseResult): string {
|
|
113
|
+
const lines = [`ForgeQE: ${r.status.toUpperCase()} in ${Math.round(r.ms / 1000)}s`, "", r.summary.trim()]
|
|
114
|
+
const steps = stepVerdictLines(r)
|
|
115
|
+
if (steps.length) lines.push("", ...steps)
|
|
116
|
+
if (r.finalUrl) lines.push("", `Ended on ${r.finalUrl}`)
|
|
117
|
+
if (r.code?.testFile) {
|
|
118
|
+
lines.push("", `Test ${r.code.status === "passed" ? "written and passing" : r.code.status}: ${r.code.testFile}`)
|
|
119
|
+
if (r.code.command) lines.push(`Run with: ${r.code.command}`)
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n").trim()
|
|
122
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { SourcesConfig } from "../config.ts"
|
|
2
|
+
import { envSecret, htmlToText, http } from "./http.ts"
|
|
3
|
+
import { resultText } from "./jira.ts"
|
|
4
|
+
import type { CaseResult, CaseStep, TestCase, WriteBack } from "./types.ts"
|
|
5
|
+
|
|
6
|
+
type QCase = {
|
|
7
|
+
id: number
|
|
8
|
+
pid?: string
|
|
9
|
+
name: string
|
|
10
|
+
description?: string | null
|
|
11
|
+
precondition?: string | null
|
|
12
|
+
web_url?: string
|
|
13
|
+
test_steps?: { description?: string | null; expected?: string | null; order?: number }[]
|
|
14
|
+
}
|
|
15
|
+
type QRun = { id: number; pid?: string; name?: string; test_case?: { id: number } }
|
|
16
|
+
|
|
17
|
+
export function qtestApi(cfg: SourcesConfig) {
|
|
18
|
+
const q = cfg.qtest
|
|
19
|
+
if (!q) throw new Error("qTest is not set up. Add sources.qtest.url and project_id to forgeqe.yaml, or set QTEST_URL and QTEST_PROJECT_ID.")
|
|
20
|
+
const token = envSecret(q.tokenEnv, "qTest")
|
|
21
|
+
return { base: q.url, api: `${q.url}/api/v3/projects/${q.projectId}`, auth: /^bearer\s/i.test(token) ? token : `Bearer ${token}`, statuses: q.statuses }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toCase(c: QCase, run?: QRun): TestCase {
|
|
25
|
+
const steps: CaseStep[] = [...(c.test_steps || [])]
|
|
26
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
27
|
+
.map((s) => ({ action: htmlToText(s.description || ""), expected: htmlToText(s.expected || "") || undefined }))
|
|
28
|
+
.filter((s) => s.action)
|
|
29
|
+
return {
|
|
30
|
+
id: c.pid || `TC-${c.id}`,
|
|
31
|
+
title: c.name,
|
|
32
|
+
source: "qtest",
|
|
33
|
+
url: c.web_url,
|
|
34
|
+
description: htmlToText(c.description || "") || undefined,
|
|
35
|
+
preconditions: htmlToText(c.precondition || "") ? [htmlToText(c.precondition || "")] : undefined,
|
|
36
|
+
steps,
|
|
37
|
+
ref: { testCase: c.id, ...(run ? { run: run.id } : {}) },
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `qtest:12345,12346` (test case ids from the URL), `qtest:cycle=678` or `qtest:suite=910` (their test runs; results go to those runs). */
|
|
42
|
+
export async function qtestCases(ref: string, cfg: SourcesConfig): Promise<TestCase[]> {
|
|
43
|
+
const q = qtestApi(cfg)
|
|
44
|
+
const getCase = async (id: number) => (await http<QCase>(`${q.api}/test-cases/${id}?expandSteps=true`, { auth: q.auth })).data
|
|
45
|
+
const parent = ref.match(/^(cycle|suite)=(\d+)$/i)
|
|
46
|
+
if (parent) {
|
|
47
|
+
const type = parent[1].toLowerCase() === "cycle" ? "test-cycle" : "test-suite"
|
|
48
|
+
const runs: QRun[] = []
|
|
49
|
+
for (let page = 1; ; page++) {
|
|
50
|
+
const { data } = await http<QRun[] | { items?: QRun[] }>(`${q.api}/test-runs?parentId=${parent[2]}&parentType=${type}&page=${page}&pageSize=100`, { auth: q.auth })
|
|
51
|
+
const items = Array.isArray(data) ? data : data?.items || []
|
|
52
|
+
runs.push(...items)
|
|
53
|
+
if (items.length < 100) break
|
|
54
|
+
}
|
|
55
|
+
const out: TestCase[] = []
|
|
56
|
+
for (const run of runs) if (run.test_case?.id) out.push(toCase(await getCase(run.test_case.id), run))
|
|
57
|
+
if (!out.length) throw new Error(`No test runs in qTest ${type} ${parent[2]}.`)
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
const ids = ref.split(/[,\s]+/).map((s) => Number(s)).filter((n) => Number.isInteger(n) && n > 0)
|
|
61
|
+
if (!ids.length) throw new Error(`qTest reference "${ref}": use qtest:12345 (the test case id from its URL), qtest:cycle=678, or qtest:suite=910.`)
|
|
62
|
+
const out: TestCase[] = []
|
|
63
|
+
for (const id of ids) out.push(toCase(await getCase(id)))
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** An automation test log per case, on the test run it came from; cases read by id get test runs in a new test cycle. */
|
|
68
|
+
export async function qtestWriteBack(results: CaseResult[], cfg: SourcesConfig, runName: string): Promise<WriteBack[]> {
|
|
69
|
+
const q = qtestApi(cfg)
|
|
70
|
+
const status = { passed: q.statuses.passed, failed: q.statuses.failed, error: q.statuses.error } as const
|
|
71
|
+
const stepStatus = { passed: q.statuses.passed, failed: q.statuses.failed, not_run: q.statuses.error } as const
|
|
72
|
+
const out: WriteBack[] = []
|
|
73
|
+
let cycle: number | undefined
|
|
74
|
+
let logged = 0
|
|
75
|
+
for (const r of results) {
|
|
76
|
+
try {
|
|
77
|
+
let runId = Number(r.case.ref?.run || 0)
|
|
78
|
+
if (!runId) {
|
|
79
|
+
if (!cycle) cycle = (await http<{ id: number }>(`${q.api}/test-cycles?parentId=0&parentType=root`, { auth: q.auth, body: { name: runName } })).data.id
|
|
80
|
+
runId = (await http<{ id: number }>(`${q.api}/test-runs?parentId=${cycle}&parentType=test-cycle`, { auth: q.auth, body: { name: r.case.title, test_case: { id: Number(r.case.ref?.testCase) } } })).data.id
|
|
81
|
+
}
|
|
82
|
+
const verdicts = new Map((r.stepResults || []).map((v) => [v.step, v]))
|
|
83
|
+
const body: Record<string, unknown> = {
|
|
84
|
+
name: r.case.title,
|
|
85
|
+
status: status[r.status as keyof typeof status] ?? q.statuses.error,
|
|
86
|
+
exe_start_date: r.startedAt,
|
|
87
|
+
exe_end_date: r.finishedAt,
|
|
88
|
+
note: resultText(r).slice(0, 4000),
|
|
89
|
+
}
|
|
90
|
+
const withSteps = verdicts.size
|
|
91
|
+
? {
|
|
92
|
+
...body,
|
|
93
|
+
test_step_logs: r.case.steps.map((s, i) => {
|
|
94
|
+
const v = verdicts.get(i + 1)
|
|
95
|
+
return { description: s.action, expected_result: s.expected || "", actual_result: v?.actual || "", status: v ? stepStatus[v.status] : q.statuses.error, order: i + 1 }
|
|
96
|
+
}),
|
|
97
|
+
}
|
|
98
|
+
: body
|
|
99
|
+
const url = `${q.api}/test-runs/${runId}/auto-test-logs`
|
|
100
|
+
await http(url, { auth: q.auth, body: withSteps }).catch((err) => (withSteps === body ? Promise.reject(err) : http(url, { auth: q.auth, body })))
|
|
101
|
+
logged++
|
|
102
|
+
} catch (err) {
|
|
103
|
+
out.push({ source: "qtest", caseId: r.case.id, text: `${r.case.id}: result not recorded`, error: (err as Error).message })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (logged) out.push({ source: "qtest", text: `qTest: ${logged} test log${logged === 1 ? "" : "s"}${cycle ? ` (new test cycle ${cycle})` : ""}` })
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { findPage, type MemoryPage } from "../memory.ts"
|
|
2
|
+
import type { TestCase } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test case quality, checked before a case costs a browser run: steps a browser agent cannot follow, or expected
|
|
6
|
+
* results it cannot check, make a run fail for the test case's sake rather than the application's. These checks are
|
|
7
|
+
* rules (no model): what they cannot judge (whether a step is ambiguous for this application) is left to the agent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type CheckSeverity = "blocker" | "warning" | "info"
|
|
11
|
+
|
|
12
|
+
export type CheckCode =
|
|
13
|
+
| "no_steps"
|
|
14
|
+
| "no_expected_results"
|
|
15
|
+
| "step_without_expected"
|
|
16
|
+
| "vague_expected"
|
|
17
|
+
| "missing_test_data"
|
|
18
|
+
| "placeholder"
|
|
19
|
+
| "credential_in_case"
|
|
20
|
+
| "several_actions"
|
|
21
|
+
| "no_start"
|
|
22
|
+
| "duplicate"
|
|
23
|
+
| "long_case"
|
|
24
|
+
| "not_on_page"
|
|
25
|
+
|
|
26
|
+
export type CaseFinding = { code: CheckCode; severity: CheckSeverity; step?: number; message: string; suggestion: string }
|
|
27
|
+
|
|
28
|
+
/** ready: nothing to fix; review: worth fixing, it runs; not_runnable: a browser agent cannot test it as written. */
|
|
29
|
+
export type CaseVerdict = "ready" | "review" | "not_runnable"
|
|
30
|
+
|
|
31
|
+
export type CaseCheck = { id: string; title: string; url?: string; verdict: CaseVerdict; findings: CaseFinding[] }
|
|
32
|
+
|
|
33
|
+
const VAGUE = /^(it |the (page|app|application|system|form) )?(works?|working|ok|okay|fine|success(ful(ly)?)?|correct(ly)?|proper(ly)?|as expected|no (error|issue)s?|should work|passes|is (displayed|shown|done))\.?$/i
|
|
34
|
+
const VAGUE_WORDS = /\b(as expected|works? (fine|correctly|properly)|correctly|properly|appropriate(ly)?|successfully)\b/i
|
|
35
|
+
const NEEDS_DATA = /\b(valid|invalid|some|any|appropriate|relevant|correct|required|mandatory|test)\s+(details|data|values?|information|info|inputs?|users?|credentials|e-?mail( address)?|password|name|address)\b/i
|
|
36
|
+
const PLACEHOLDER = /\b(TBD|TODO|FIXME|xxx+|lorem ipsum)\b|<[^<>\s]{2,30}>|\{\{[^}]*\}\}|\[(insert|enter|add)[^\]]*\]/i
|
|
37
|
+
const CREDENTIAL = /\b(password|passwd|pwd|secret|api[ -]?key|token)\s*[:=]\s*\S+/i
|
|
38
|
+
const START = /\b(https?:\/\/|www\.|navigate|open|go to|launch|visit|browse to|log ?in|sign ?in)\b/i
|
|
39
|
+
|
|
40
|
+
const words = (s: string) => s.trim().split(/\s+/).filter(Boolean).length
|
|
41
|
+
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()
|
|
42
|
+
|
|
43
|
+
/** Every rule on one case; duplicates are found across cases by checkCases. */
|
|
44
|
+
export function checkCase(tc: TestCase): CaseFinding[] {
|
|
45
|
+
const out: CaseFinding[] = []
|
|
46
|
+
const steps = tc.steps
|
|
47
|
+
if (!steps.length) {
|
|
48
|
+
out.push(
|
|
49
|
+
tc.description?.trim()
|
|
50
|
+
? { code: "no_steps", severity: "warning", message: "No numbered steps: the browser agent works from the description alone.", suggestion: "Write the description as numbered steps, each with an expected result." }
|
|
51
|
+
: { code: "no_steps", severity: "blocker", message: "No steps and no description: there is nothing to test.", suggestion: "Add numbered steps, each with an expected result." },
|
|
52
|
+
)
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
const withExpected = steps.filter((s) => s.expected?.trim())
|
|
56
|
+
if (!withExpected.length) {
|
|
57
|
+
out.push({ code: "no_expected_results", severity: "blocker", message: "No step has an expected result, so the run cannot pass or fail on anything.", suggestion: "Give at least the steps that matter an expected result the page shows (a message, a value, a page)." })
|
|
58
|
+
}
|
|
59
|
+
steps.forEach((s, i) => {
|
|
60
|
+
const n = i + 1
|
|
61
|
+
const text = `${s.action} ${s.data || ""} ${s.expected || ""}`
|
|
62
|
+
const last = i === steps.length - 1
|
|
63
|
+
if (withExpected.length && !s.expected?.trim() && last) {
|
|
64
|
+
out.push({ code: "step_without_expected", severity: "warning", step: n, message: "The last step has no expected result, so the case does not say how it ends.", suggestion: "Say what the page shows when the case has passed." })
|
|
65
|
+
}
|
|
66
|
+
if (s.expected?.trim() && (VAGUE.test(s.expected.trim()) || (VAGUE_WORDS.test(s.expected) && words(s.expected) <= 6))) {
|
|
67
|
+
out.push({
|
|
68
|
+
code: "vague_expected",
|
|
69
|
+
severity: "warning",
|
|
70
|
+
step: n,
|
|
71
|
+
message: `"${s.expected.trim()}" does not say what the page shows, so a run can only guess what passing means.`,
|
|
72
|
+
suggestion: "Name what is on the page when it works: a confirmation message, a value, a heading, a URL.",
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
if (NEEDS_DATA.test(s.action) && !s.data?.trim() && !/["'“][^"'”]+["'”]|\d/.test(s.action)) {
|
|
76
|
+
out.push({ code: "missing_test_data", severity: "warning", step: n, message: `"${clip(s.action, 80)}" does not give the values to use.`, suggestion: "Give the values (or a data table) the step should use, or say where they come from." })
|
|
77
|
+
}
|
|
78
|
+
if (PLACEHOLDER.test(text)) out.push({ code: "placeholder", severity: "blocker", step: n, message: `An unfilled placeholder: "${clip(PLACEHOLDER.exec(text)![0], 40)}".`, suggestion: "Replace the placeholder with the real value." })
|
|
79
|
+
if (CREDENTIAL.test(text)) {
|
|
80
|
+
out.push({ code: "credential_in_case", severity: "warning", step: n, message: "A password or key is written in the test case.", suggestion: "Keep credentials out of test cases: use a saved login (logins: in forgeqe.yaml) and say \"sign in as <role>\"." })
|
|
81
|
+
}
|
|
82
|
+
if ((s.action.match(/\b(and then|then|and)\b|;/gi) || []).length >= 3) {
|
|
83
|
+
out.push({ code: "several_actions", severity: "info", step: n, message: "Several actions in one step, so a failure cannot say which of them went wrong.", suggestion: "Split it into one action per step." })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
if (!tc.startUrl && !START.test(steps[0].action) && !START.test(tc.description || "") && !(tc.preconditions || []).some((p) => START.test(p))) {
|
|
87
|
+
out.push({ code: "no_start", severity: "warning", step: 1, message: "The case does not say where to start (a URL or page).", suggestion: "Start with \"Open <URL>\", or set the start page in the case." })
|
|
88
|
+
}
|
|
89
|
+
if (steps.length > 25) out.push({ code: "long_case", severity: "info", message: `${steps.length} steps in one case: one failure stops the rest from being checked.`, suggestion: "Split it into cases that each check one behaviour." })
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Fields and controls a step can name without quoting them: "enter a valid email address". */
|
|
94
|
+
const CONTROL_WORDS = [
|
|
95
|
+
"email", "e-mail", "password", "username", "user name", "first name", "last name", "full name", "phone", "mobile",
|
|
96
|
+
"address", "postcode", "zip", "city", "country", "date of birth", "date", "search", "message", "comment", "subject",
|
|
97
|
+
"company", "quantity", "coupon", "promo code", "captcha", "terms", "upload", "file",
|
|
98
|
+
]
|
|
99
|
+
const QUOTED = /["“'‘]([^"”'’]{2,40})["”'’]/g
|
|
100
|
+
const BEFORE_CONTROL = /((?:[A-Za-z][\w-]*\s+){0,2}[A-Za-z][\w-]*)\s+(?:field|button|link|checkbox|check box|dropdown|drop-down|radio|tab|menu|textbox|text box|input|option)\b/gi
|
|
101
|
+
const FILLER = new Set(["the", "a", "an", "all", "any", "valid", "invalid", "new", "mandatory", "required", "and", "or", "then", "from", "on", "in", "into", "to", "click", "tick", "untick", "select", "choose", "enter", "type", "press", "open", "fill", "check", "uncheck", "its", "this", "that", "of"])
|
|
102
|
+
const URL = /https?:\/\/[^\s"'<>)]+/i
|
|
103
|
+
|
|
104
|
+
/** What a step names on the page: quoted labels, "X button" / "X field", and common field words. */
|
|
105
|
+
export function namedControls(action: string): string[] {
|
|
106
|
+
const out = new Set<string>()
|
|
107
|
+
const keep = (name: string) => {
|
|
108
|
+
const n = name.trim().toLowerCase()
|
|
109
|
+
if (n && !URL.test(n) && !/^\d+$/.test(n)) out.add(n)
|
|
110
|
+
}
|
|
111
|
+
for (const m of action.matchAll(QUOTED)) keep(m[1])
|
|
112
|
+
const unquoted = action.replace(QUOTED, " ")
|
|
113
|
+
for (const m of unquoted.matchAll(BEFORE_CONTROL)) {
|
|
114
|
+
// The control's name is the last word or two before "button", "field"…, without the verbs and articles around it.
|
|
115
|
+
const words = m[1].split(/\s+/)
|
|
116
|
+
while (words.length && FILLER.has(words[0].toLowerCase())) words.shift()
|
|
117
|
+
if (words.length) keep(words.join(" "))
|
|
118
|
+
}
|
|
119
|
+
const low = action.toLowerCase()
|
|
120
|
+
for (const w of CONTROL_WORDS) if (new RegExp(`\\b${w}\\b`).test(low)) out.add(w)
|
|
121
|
+
// "email address" is the email field, not an address field.
|
|
122
|
+
if (/\bemail address\b/.test(low) && !/\baddress (field|line)\b/.test(low)) out.delete("address")
|
|
123
|
+
return [...out]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const label = (e: MemoryPage["elements"][string]) =>
|
|
127
|
+
[e.name, e.text, e.attributes?.placeholder, e.attributes?.["aria-label"], e.attributes?.name, e.attributes?.id, e.attributes?.title, e.attributes?.type]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join(" ")
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Steps that name a field or control earlier runs never saw on that page: page memory has every element ForgeQE met
|
|
134
|
+
* on each page. Only pages it knows well (several visits, a fair number of elements) are judged, and a miss is a
|
|
135
|
+
* warning: the control may appear later (after a click), or the page may have changed.
|
|
136
|
+
*/
|
|
137
|
+
export function checkAgainstPages(tc: TestCase, pages: MemoryPage[]): CaseFinding[] {
|
|
138
|
+
const out: CaseFinding[] = []
|
|
139
|
+
let url = tc.startUrl || ""
|
|
140
|
+
tc.steps.forEach((s, i) => {
|
|
141
|
+
const found = URL.exec(`${s.action} ${s.data || ""}`)?.[0]
|
|
142
|
+
if (found) {
|
|
143
|
+
url = found.replace(/[.,;]+$/, "")
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (!url) return
|
|
147
|
+
const page = findPage(pages, url)
|
|
148
|
+
const elements = page ? Object.values(page.elements) : []
|
|
149
|
+
if (!page || elements.length < 8 || (page.visits || 0) < 1) return
|
|
150
|
+
const labels = elements.map(label)
|
|
151
|
+
const missing = namedControls(s.action).filter((name) => !labels.some((l) => l.includes(name.replace(/-/g, "")) || l.includes(name)))
|
|
152
|
+
if (!missing.length) return
|
|
153
|
+
const seen = [...new Set(elements.filter((e) => e.interactions || ["input", "select", "textarea", "button"].includes(e.tag)).map((e) => (e.name || e.attributes?.placeholder || e.text || "").trim()).filter((n) => n && n.length <= 30))].slice(0, 10)
|
|
154
|
+
out.push({
|
|
155
|
+
code: "not_on_page",
|
|
156
|
+
severity: "warning",
|
|
157
|
+
step: i + 1,
|
|
158
|
+
message: `${missing.map((m) => `"${m}"`).join(", ")} ${missing.length === 1 ? "was" : "were"} never seen on ${page.host}${page.path} in ${page.visits} earlier ${page.visits === 1 ? "visit" : "visits"}${seen.length ? `; it has ${seen.join(", ")}` : ""}.`,
|
|
159
|
+
suggestion: "Check the step against the page: the control may have another name, sit on another page, or not exist (then the test case needs updating).",
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function verdictOf(findings: CaseFinding[]): CaseVerdict {
|
|
166
|
+
if (findings.some((f) => f.severity === "blocker")) return "not_runnable"
|
|
167
|
+
if (findings.some((f) => f.severity === "warning")) return "review"
|
|
168
|
+
return "ready"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Check each case (against page memory too, when given), and cases whose steps are the same as an earlier one's. */
|
|
172
|
+
export function checkCases(cases: TestCase[], pages: MemoryPage[] = []): CaseCheck[] {
|
|
173
|
+
const seen = new Map<string, TestCase>()
|
|
174
|
+
return cases.map((tc) => {
|
|
175
|
+
const findings = [...checkCase(tc), ...(pages.length ? checkAgainstPages(tc, pages) : [])]
|
|
176
|
+
const signature = tc.steps.map((s) => `${norm(s.action)}>${norm(s.expected || "")}`).join("|")
|
|
177
|
+
const same = signature && seen.get(signature)
|
|
178
|
+
if (same) findings.push({ code: "duplicate", severity: "warning", message: `Same steps and expected results as ${same.id} "${same.title}".`, suggestion: `Merge it with ${same.id}, or make the difference explicit.` })
|
|
179
|
+
else if (signature) seen.set(signature, tc)
|
|
180
|
+
return { id: tc.id, title: tc.title, ...(tc.url ? { url: tc.url } : {}), verdict: verdictOf(findings), findings }
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** "3 ready, 1 to review, 1 not runnable". */
|
|
185
|
+
export function checkSummary(checks: CaseCheck[]): string {
|
|
186
|
+
const n = (v: CaseVerdict) => checks.filter((c) => c.verdict === v).length
|
|
187
|
+
return [`${n("ready")} ready`, n("review") ? `${n("review")} to review` : "", n("not_runnable") ? `${n("not_runnable")} not runnable` : ""].filter(Boolean).join(", ")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
|
package/src/cases/rag.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
import { withoutTelemetry } from "../privacy.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An optional retrieval service for requirements (a RAG index over Confluence, a wiki, specs, tickets): ForgeQE asks
|
|
7
|
+
* it for the passages relevant to a test case and adds them to the quality check. Plug and play, either kind:
|
|
8
|
+
*
|
|
9
|
+
* rag:
|
|
10
|
+
* url: http://localhost:8080/search # POST {"query": "...", "top_k": 5}
|
|
11
|
+
* token_env: RAG_TOKEN # optional: sent as "Authorization: Bearer <value>"
|
|
12
|
+
* top_k: 5
|
|
13
|
+
*
|
|
14
|
+
* rag:
|
|
15
|
+
* command: ["python", "rag/search.py"] # gets {"query": "...", "top_k": 5} on stdin, prints the answer on stdout
|
|
16
|
+
*
|
|
17
|
+
* The answer is a JSON list of passages, or an object with one under results, passages, documents, or hits. A passage
|
|
18
|
+
* is { text, source, score } (also content / page_content for the text, url / title / metadata.source for the
|
|
19
|
+
* source). A RAG server reachable over MCP (under mcp: in forgeqe.yaml) works too: ForgeQE's agent calls its tools.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type RagConfig = { url?: string; command?: string[]; tokenEnv?: string; topK: number; timeoutMs: number }
|
|
23
|
+
export type Passage = { text: string; source?: string; score?: number }
|
|
24
|
+
|
|
25
|
+
const MAX_TEXT = 1500
|
|
26
|
+
|
|
27
|
+
export function ragConfig(raw: unknown): RagConfig | undefined {
|
|
28
|
+
if (!raw || typeof raw !== "object") return undefined
|
|
29
|
+
const r = raw as Record<string, unknown>
|
|
30
|
+
const url = r.url ? String(r.url) : undefined
|
|
31
|
+
const command = Array.isArray(r.command) ? r.command.map(String) : typeof r.command === "string" ? r.command.split(/\s+/).filter(Boolean) : undefined
|
|
32
|
+
if (!url && !command?.length) return undefined
|
|
33
|
+
return {
|
|
34
|
+
...(url ? { url } : {}),
|
|
35
|
+
...(command?.length ? { command } : {}),
|
|
36
|
+
...(r.token_env ? { tokenEnv: String(r.token_env) } : {}),
|
|
37
|
+
topK: Number(r.top_k) > 0 ? Math.min(20, Number(r.top_k)) : 5,
|
|
38
|
+
timeoutMs: Number(r.timeout_seconds) > 0 ? Number(r.timeout_seconds) * 1000 : 20_000,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Passages from any of the answer shapes above, best first, text shortened. */
|
|
43
|
+
export function passagesFrom(answer: unknown): Passage[] {
|
|
44
|
+
const list = Array.isArray(answer)
|
|
45
|
+
? answer
|
|
46
|
+
: answer && typeof answer === "object"
|
|
47
|
+
? ((answer as Record<string, unknown>).results ?? (answer as Record<string, unknown>).passages ?? (answer as Record<string, unknown>).documents ?? (answer as Record<string, unknown>).hits)
|
|
48
|
+
: undefined
|
|
49
|
+
if (!Array.isArray(list)) return []
|
|
50
|
+
return list
|
|
51
|
+
.map((p): Passage | null => {
|
|
52
|
+
if (typeof p === "string") return { text: p.slice(0, MAX_TEXT) }
|
|
53
|
+
if (!p || typeof p !== "object") return null
|
|
54
|
+
const o = p as Record<string, any>
|
|
55
|
+
const text = String(o.text ?? o.content ?? o.page_content ?? o.chunk ?? "").trim()
|
|
56
|
+
if (!text) return null
|
|
57
|
+
const source = o.source ?? o.url ?? o.title ?? o.metadata?.source ?? o.metadata?.url ?? o.metadata?.title
|
|
58
|
+
const score = typeof o.score === "number" ? o.score : typeof o.similarity === "number" ? o.similarity : undefined
|
|
59
|
+
return { text: text.slice(0, MAX_TEXT), ...(source ? { source: String(source) } : {}), ...(score !== undefined ? { score } : {}) }
|
|
60
|
+
})
|
|
61
|
+
.filter((p): p is Passage => p !== null)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ask the configured service; throws with what went wrong, so the check can say why there are no passages. */
|
|
65
|
+
export async function searchRequirements(cfg: RagConfig, query: string): Promise<Passage[]> {
|
|
66
|
+
const body = JSON.stringify({ query, top_k: cfg.topK })
|
|
67
|
+
if (cfg.url) {
|
|
68
|
+
const token = cfg.tokenEnv ? process.env[cfg.tokenEnv] : undefined
|
|
69
|
+
if (cfg.tokenEnv && !token) throw new Error(`rag.token_env names ${cfg.tokenEnv}, which is not set`)
|
|
70
|
+
const res = await fetch(cfg.url, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
73
|
+
body,
|
|
74
|
+
signal: AbortSignal.timeout(cfg.timeoutMs),
|
|
75
|
+
})
|
|
76
|
+
if (!res.ok) throw new Error(`the RAG service answered ${res.status}`)
|
|
77
|
+
return passagesFrom(await res.json()).slice(0, cfg.topK)
|
|
78
|
+
}
|
|
79
|
+
const [bin, ...args] = cfg.command!
|
|
80
|
+
const out = await new Promise<string>((resolve, reject) => {
|
|
81
|
+
const child = spawn(bin, args, { env: withoutTelemetry(process.env), stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" })
|
|
82
|
+
let stdout = ""
|
|
83
|
+
let stderr = ""
|
|
84
|
+
const timer = setTimeout(() => child.kill(), cfg.timeoutMs)
|
|
85
|
+
child.stdout.on("data", (d) => (stdout += d))
|
|
86
|
+
child.stderr.on("data", (d) => (stderr += d))
|
|
87
|
+
child.on("error", reject)
|
|
88
|
+
child.on("close", (code) => {
|
|
89
|
+
clearTimeout(timer)
|
|
90
|
+
if (code === 0) resolve(stdout)
|
|
91
|
+
else reject(new Error(`the RAG command exited ${code}: ${stderr.trim().slice(-300)}`))
|
|
92
|
+
})
|
|
93
|
+
child.stdin.end(body)
|
|
94
|
+
})
|
|
95
|
+
try {
|
|
96
|
+
return passagesFrom(JSON.parse(out)).slice(0, cfg.topK)
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error("the RAG command did not print JSON")
|
|
99
|
+
}
|
|
100
|
+
}
|