@capagents/forgeqe 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +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 +757 -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 +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small pieces of Playwright aria snapshots (the page as roles and accessible names, one YAML line per element) around
|
|
5
|
+
* the element a step or a failure is about: its section, its neighbours, and the line itself. Scribe gets these in its
|
|
6
|
+
* prompt instead of whole snapshots or a live browser, which re-send thousands of lines with every later call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What a Playwright locator points at, in terms an aria snapshot shows: a role and accessible name, or text. */
|
|
10
|
+
export type AriaTarget = { role?: string; name?: string; text?: string }
|
|
11
|
+
|
|
12
|
+
const STRING = `['"\`]((?:[^'"\`\\\\]|\\\\.)*)['"\`]`
|
|
13
|
+
|
|
14
|
+
/** The role and name, or the text, of a Playwright locator; null for CSS, XPath, and test ids, which a snapshot does not show. */
|
|
15
|
+
export function ariaTarget(locator: string): AriaTarget | null {
|
|
16
|
+
const role = new RegExp(`getByRole\\(\\s*${STRING}(?:\\s*,\\s*\\{([^}]*)\\})?`).exec(locator)
|
|
17
|
+
if (role) {
|
|
18
|
+
const opts = role[2] || ""
|
|
19
|
+
const name = new RegExp(`name:\\s*${STRING}`).exec(opts)?.[1] ?? /name:\s*\/(.*?)\/[a-z]*/.exec(opts)?.[1]
|
|
20
|
+
return { role: role[1], ...(name ? { name: unescape(name) } : {}) }
|
|
21
|
+
}
|
|
22
|
+
const text = new RegExp(`getBy(?:Label|Placeholder|Text|Title|AltText)\\(\\s*${STRING}`).exec(locator)
|
|
23
|
+
if (text) return { text: unescape(text[1]) }
|
|
24
|
+
const has = new RegExp(`hasText:\\s*${STRING}`).exec(locator)
|
|
25
|
+
if (has) return { text: unescape(has[1]) }
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const unescape = (s: string) => s.replace(/\\(.)/g, "$1")
|
|
30
|
+
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase()
|
|
31
|
+
const indentOf = (line: string) => line.length - line.trimStart().length
|
|
32
|
+
|
|
33
|
+
/** The quoted name on an aria line (`- button "Search"`), or its text (`- text: Search`). */
|
|
34
|
+
function lineName(line: string): string {
|
|
35
|
+
const t = line.trim()
|
|
36
|
+
return /^- [\w-]+ "((?:[^"\\]|\\.)*)"/.exec(t)?.[1] ?? /^- [\w-]+: (.+)$/.exec(t)?.[1] ?? ""
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The line the target is on: exact name first, then a name that contains it. -1 when the page does not show it. */
|
|
40
|
+
export function findLine(lines: string[], target: AriaTarget): number {
|
|
41
|
+
const want = norm(target.name ?? target.text ?? "")
|
|
42
|
+
const roleOk = (l: string) => !target.role || l.trim().startsWith(`- ${target.role}`)
|
|
43
|
+
const candidates = lines.map((l, i) => ({ l, i })).filter(({ l }) => l.trim().startsWith("-") && roleOk(l))
|
|
44
|
+
if (!want) return candidates[0]?.i ?? -1
|
|
45
|
+
const exact = candidates.find(({ l }) => norm(lineName(l)) === want)
|
|
46
|
+
if (exact) return exact.i
|
|
47
|
+
const within = candidates.find(({ l }) => norm(lineName(l)).includes(want))
|
|
48
|
+
if (within) return within.i
|
|
49
|
+
// Text locators also match a label's text inside a longer line.
|
|
50
|
+
if (!target.role) return lines.findIndex((l) => norm(l).includes(want))
|
|
51
|
+
return -1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The line that shares the most words with the target's name: where to look when the element itself is gone. */
|
|
55
|
+
function closestLine(lines: string[], target: AriaTarget): number {
|
|
56
|
+
const words = norm(target.name ?? target.text ?? "").split(" ").filter((w) => w.length >= 4)
|
|
57
|
+
if (!words.length) return -1
|
|
58
|
+
let best = -1
|
|
59
|
+
let score = 0
|
|
60
|
+
lines.forEach((l, i) => {
|
|
61
|
+
const n = norm(l)
|
|
62
|
+
const s = words.filter((w) => n.includes(w)).length
|
|
63
|
+
if (s > score) {
|
|
64
|
+
score = s
|
|
65
|
+
best = i
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
return best
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The element's section of the snapshot: from its grandparent (or parent) down, at most `max` lines, kept around the
|
|
73
|
+
* element, with the element's line marked. Long lines are shortened.
|
|
74
|
+
*/
|
|
75
|
+
export function sliceAround(lines: string[], at: number, mark: string, max = 18): string[] {
|
|
76
|
+
let top = at
|
|
77
|
+
for (let level = 0; level < 2; level++) {
|
|
78
|
+
const want = indentOf(lines[top])
|
|
79
|
+
let j = top - 1
|
|
80
|
+
while (j >= 0 && indentOf(lines[j]) >= want) j--
|
|
81
|
+
if (j < 0) break
|
|
82
|
+
top = j
|
|
83
|
+
}
|
|
84
|
+
let end = top + 1
|
|
85
|
+
while (end < lines.length && indentOf(lines[end]) > indentOf(lines[top])) end++
|
|
86
|
+
let from = top
|
|
87
|
+
let to = end
|
|
88
|
+
if (to - from > max) {
|
|
89
|
+
from = Math.max(top, at - Math.floor(max / 2))
|
|
90
|
+
to = Math.min(end, from + max)
|
|
91
|
+
from = Math.max(top, to - max)
|
|
92
|
+
}
|
|
93
|
+
const clip = (l: string) => (l.length > 160 ? `${l.slice(0, 159)}…` : l)
|
|
94
|
+
const out = lines.slice(from, to).map((l, k) => (from + k === at ? `${clip(l)} # ← ${mark}` : clip(l)))
|
|
95
|
+
const base = Math.min(...out.map(indentOf))
|
|
96
|
+
const shifted = out.map((l) => l.slice(base))
|
|
97
|
+
if (from > top) shifted.unshift(`${" ".repeat(Math.max(0, indentOf(lines[top]) - base))}${clip(lines[top].trim())}`, "…")
|
|
98
|
+
if (to < end) shifted.push("…")
|
|
99
|
+
return shifted
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The slice for a locator on one snapshot, or null when the snapshot does not show what it points at. */
|
|
103
|
+
export function sliceFor(snapshot: string, locator: string, mark: string, max?: number): string[] | null {
|
|
104
|
+
const target = ariaTarget(locator)
|
|
105
|
+
if (!target) return null
|
|
106
|
+
const lines = snapshot.split("\n")
|
|
107
|
+
const at = findLine(lines, target)
|
|
108
|
+
return at < 0 ? null : sliceAround(lines, at, mark, max)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The locator a Playwright failure names: "waiting for getByRole(…)" or "Locator: getByText(…)". */
|
|
112
|
+
export function failingLocator(output: string): string | null {
|
|
113
|
+
const m = /(?:waiting for|Locator:)\s+((?:page\.)?(?:getBy\w+|locator)\(.+?\))(?:\s+to\b|\s*$)/m.exec(output)
|
|
114
|
+
return m ? m[1] : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function read(file?: string): string {
|
|
118
|
+
if (!file) return ""
|
|
119
|
+
try {
|
|
120
|
+
return readFileSync(file, "utf8")
|
|
121
|
+
} catch {
|
|
122
|
+
return ""
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* For a failed test run: the element the failure names, on the test's page at the failure and on the page ForgeQE's
|
|
128
|
+
* replay of the run saw. Usually enough to fix a locator, a wait, or an overlay without opening a browser.
|
|
129
|
+
*/
|
|
130
|
+
export function failureContext(output: string, testSnapshot: string | undefined, runSnapshots: string[]): string[] {
|
|
131
|
+
const locator = failingLocator(output)
|
|
132
|
+
if (!locator) return []
|
|
133
|
+
const target = ariaTarget(locator)
|
|
134
|
+
if (!target) return [`The failing locator: ${locator}. It is not a role, label, or text locator, so the page snapshots cannot show it; use one of the step's role or text locators from the handoff.`]
|
|
135
|
+
const yaml = (lines: string[]) => ["```yaml", ...lines, "```"]
|
|
136
|
+
const out = [`The failing locator: ${locator}`]
|
|
137
|
+
const test = read(testSnapshot).split("\n")
|
|
138
|
+
if (test.length > 1) {
|
|
139
|
+
const at = findLine(test, target)
|
|
140
|
+
if (at >= 0) out.push(`Your test's page at the failure shows it:`, ...yaml(sliceAround(test, at, "the failing locator")))
|
|
141
|
+
else {
|
|
142
|
+
const near = closestLine(test, target)
|
|
143
|
+
out.push(
|
|
144
|
+
near >= 0
|
|
145
|
+
? "Your test's page at the failure does not show it. The closest part of that page:"
|
|
146
|
+
: "Your test's page at the failure does not show it, or anything with its words: the test is on another page, or something covers it.",
|
|
147
|
+
...(near >= 0 ? yaml(sliceAround(test, near, "closest match")) : []),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const file of runSnapshots) {
|
|
152
|
+
const run = read(file).split("\n")
|
|
153
|
+
const at = findLine(run, target)
|
|
154
|
+
if (at < 0) continue
|
|
155
|
+
out.push("The same element on ForgeQE's replay of the run:", ...yaml(sliceAround(run, at, "the element")))
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
return out
|
|
159
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { withoutNames } from "../childEnv.ts"
|
|
5
|
+
import { redactSecrets, sessionLoginEnv } from "../sessionLogins.ts"
|
|
6
|
+
|
|
7
|
+
const HEAD_CHARS = 8_000
|
|
8
|
+
const TAIL_CHARS = 4_000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `browsers`: automation-controlled browsers that ran under the command, e.g. "Google Chrome (headless)".
|
|
12
|
+
* `output` is short enough for a prompt with the failure message kept; `log` and `pageSnapshot` hold what it left out.
|
|
13
|
+
*/
|
|
14
|
+
export type TestRun = { ok: boolean; exitCode: number | null; output: string; ms: number; browsers: string[]; log?: string; pageSnapshot?: string }
|
|
15
|
+
|
|
16
|
+
type Proc = { pid: number; ppid: number; args: string }
|
|
17
|
+
|
|
18
|
+
/** Switches Playwright, Puppeteer, Selenium, Cypress, and WebDriver BiDi put on the browsers they launch. */
|
|
19
|
+
const AUTOMATION_SWITCHES = ["--remote-debugging-pipe", "--remote-debugging-port", "-juggler-pipe", "--inspector-pipe", "-marionette", "--marionette"]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Playwright puts the whole page's accessibility tree into a failed assertion's message ("Aria snapshot:" and a YAML
|
|
23
|
+
* list, often thousands of lines), between the error and the stack trace. Each one is taken out and returned apart.
|
|
24
|
+
*/
|
|
25
|
+
export function withoutPageDumps(text: string): { text: string; dumps: string[] } {
|
|
26
|
+
const lines = text.split("\n")
|
|
27
|
+
const out: string[] = []
|
|
28
|
+
const dumps: string[] = []
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
if (!/^\s*Aria snapshot:\s*$/.test(lines[i])) {
|
|
31
|
+
out.push(lines[i])
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
let j = i + 1
|
|
35
|
+
while (j < lines.length && /^\s*- /.test(lines[j])) j++
|
|
36
|
+
if (j === i + 1) {
|
|
37
|
+
out.push(lines[i])
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
dumps.push(lines.slice(i + 1, j).join("\n"))
|
|
41
|
+
out.push(`${lines[i].replace(/:\s*$/, "")}: ${j - i - 1} lines of the page, left out here {{snapshot}}`)
|
|
42
|
+
i = j - 1
|
|
43
|
+
}
|
|
44
|
+
return { text: out.join("\n"), dumps }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The start (where test runners put the first failure) and the end (the summary) of a long output. */
|
|
48
|
+
export function headAndTail(text: string, note = ""): string {
|
|
49
|
+
if (text.length <= HEAD_CHARS + TAIL_CHARS) return text
|
|
50
|
+
const head = text.slice(0, HEAD_CHARS)
|
|
51
|
+
const tail = text.slice(-TAIL_CHARS)
|
|
52
|
+
const cut = text.slice(HEAD_CHARS, -TAIL_CHARS).split("\n").length
|
|
53
|
+
return `${head}\n… ${cut} lines left out${note ? ` (${note})` : ""} …\n${tail}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A test run's output for a prompt: page dumps out, then its start and end; the full text goes to `save`.log. */
|
|
57
|
+
export function promptOutput(full: string, save?: string): { output: string; log?: string; pageSnapshot?: string } {
|
|
58
|
+
const { text, dumps } = withoutPageDumps(full)
|
|
59
|
+
let log: string | undefined
|
|
60
|
+
let pageSnapshot: string | undefined
|
|
61
|
+
if (save) {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(path.dirname(save), { recursive: true })
|
|
64
|
+
log = `${save}.log`
|
|
65
|
+
writeFileSync(log, full)
|
|
66
|
+
if (dumps.length) {
|
|
67
|
+
pageSnapshot = `${save}-page.aria.yml`
|
|
68
|
+
writeFileSync(pageSnapshot, dumps[0])
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
log = pageSnapshot = undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const where = pageSnapshot ? `(saved in ${pageSnapshot})` : ""
|
|
75
|
+
const output = headAndTail(text.replaceAll(" {{snapshot}}", where ? ` ${where}` : ""), log ? `full output in ${log}` : "")
|
|
76
|
+
return { output, ...(log ? { log } : {}), ...(pageSnapshot ? { pageSnapshot } : {}) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function processTable(): Promise<Proc[]> {
|
|
80
|
+
const windows = process.platform === "win32"
|
|
81
|
+
const argv = windows
|
|
82
|
+
? [
|
|
83
|
+
"powershell.exe",
|
|
84
|
+
"-NoProfile",
|
|
85
|
+
"-NonInteractive",
|
|
86
|
+
"-Command",
|
|
87
|
+
'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CommandLine)" }',
|
|
88
|
+
]
|
|
89
|
+
: ["ps", "-A", "-o", "pid=,ppid=,args="]
|
|
90
|
+
try {
|
|
91
|
+
const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "ignore" })
|
|
92
|
+
const text = await new Response(proc.stdout).text()
|
|
93
|
+
await proc.exited
|
|
94
|
+
return text.split(/\r?\n/).flatMap((line) => {
|
|
95
|
+
const m = /^\s*(\d+)\s+(\d+)\s?(.*)$/.exec(line)
|
|
96
|
+
return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), args: m[3] || "" }] : []
|
|
97
|
+
})
|
|
98
|
+
} catch {
|
|
99
|
+
return []
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** "Google Chrome (headless)" for an automation-launched browser's main process, else null. */
|
|
104
|
+
export function automatedBrowser(args: string): string | null {
|
|
105
|
+
if (/\s--type=/.test(args) || !AUTOMATION_SWITCHES.some((s) => args.includes(` ${s}`))) return null
|
|
106
|
+
const exe = args.split(/\s-{1,2}[a-z]/i)[0].trim().replace(/^"|"$/g, "")
|
|
107
|
+
const name = path.basename(exe.replace(/\\/g, "/")).replace(/\.exe$/i, "") || "browser"
|
|
108
|
+
return /\s--headless(=|\s|$)|\s-headless(\s|$)/.test(args) ? `${name} (headless)` : name
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Browsers launched for automation at or below `root` in the process tree right now; a shell may exec into its command. */
|
|
112
|
+
async function browsersUnder(root: number): Promise<string[]> {
|
|
113
|
+
const table = await processTable()
|
|
114
|
+
const children = new Map<number, Proc[]>()
|
|
115
|
+
for (const p of table) children.set(p.ppid, [...(children.get(p.ppid) || []), p])
|
|
116
|
+
const found: string[] = []
|
|
117
|
+
const queue = table.filter((p) => p.pid === root)
|
|
118
|
+
const seen = new Set<number>()
|
|
119
|
+
while (queue.length) {
|
|
120
|
+
const p = queue.shift()!
|
|
121
|
+
if (seen.has(p.pid)) continue
|
|
122
|
+
seen.add(p.pid)
|
|
123
|
+
const browser = automatedBrowser(p.args)
|
|
124
|
+
if (browser) found.push(browser)
|
|
125
|
+
queue.push(...(children.get(p.pid) || []))
|
|
126
|
+
}
|
|
127
|
+
return found
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run the agent's test command from the repo root, non-interactively. The output keeps the failure message and the
|
|
132
|
+
* summary (see promptOutput); with `save`, the full output is kept in `save`.log.
|
|
133
|
+
* While it runs, the process tree is sampled for browsers the test launched: a passing exit code alone
|
|
134
|
+
* does not show the test drove a browser.
|
|
135
|
+
*/
|
|
136
|
+
export async function runTestCommand(opts: {
|
|
137
|
+
command: string
|
|
138
|
+
cwd: string
|
|
139
|
+
timeoutSeconds: number
|
|
140
|
+
signal?: AbortSignal
|
|
141
|
+
/** Path without extension for the full output and the first page dump, e.g. <run>/tests/attempt-1. */
|
|
142
|
+
save?: string
|
|
143
|
+
/** Variables the test must not see: ForgeQE's own secrets (privateEnvNames). */
|
|
144
|
+
hide?: string[]
|
|
145
|
+
}): Promise<TestRun> {
|
|
146
|
+
const started = Date.now()
|
|
147
|
+
const windows = process.platform === "win32"
|
|
148
|
+
const argv = windows ? ["cmd.exe", "/d", "/s", "/c", `"${opts.command}"`] : ["sh", "-c", opts.command]
|
|
149
|
+
const proc = Bun.spawn(argv, {
|
|
150
|
+
cwd: opts.cwd,
|
|
151
|
+
windowsVerbatimArguments: windows,
|
|
152
|
+
env: { ...withoutNames(process.env, opts.hide || []), ...sessionLoginEnv(), CI: process.env.CI || "1", PLAYWRIGHT_HTML_OPEN: "never", FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
153
|
+
stdin: "ignore",
|
|
154
|
+
stdout: "pipe",
|
|
155
|
+
stderr: "pipe",
|
|
156
|
+
})
|
|
157
|
+
let timedOut = false
|
|
158
|
+
const kill = () => {
|
|
159
|
+
try {
|
|
160
|
+
proc.kill("SIGTERM")
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
timedOut = true
|
|
165
|
+
kill()
|
|
166
|
+
}, opts.timeoutSeconds * 1000)
|
|
167
|
+
opts.signal?.addEventListener("abort", kill, { once: true })
|
|
168
|
+
|
|
169
|
+
const browsers = new Set<string>()
|
|
170
|
+
let running = true
|
|
171
|
+
const sampling = (async () => {
|
|
172
|
+
const every = windows ? 1500 : 250
|
|
173
|
+
while (running) {
|
|
174
|
+
for (const b of await browsersUnder(proc.pid)) browsers.add(b)
|
|
175
|
+
if (running) await Bun.sleep(every)
|
|
176
|
+
}
|
|
177
|
+
})()
|
|
178
|
+
|
|
179
|
+
const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
|
|
180
|
+
running = false
|
|
181
|
+
await sampling
|
|
182
|
+
clearTimeout(timer)
|
|
183
|
+
opts.signal?.removeEventListener("abort", kill)
|
|
184
|
+
const full = redactSecrets([out, err].filter((s) => s.trim()).join("\n").replace(/\x1b\[[0-9;]*m/g, ""))
|
|
185
|
+
const ok = !timedOut && code === 0
|
|
186
|
+
return { ok, exitCode: timedOut ? null : code, ...promptOutput(full, ok ? undefined : opts.save), ms: Date.now() - started, browsers: [...browsers] }
|
|
187
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { projectDir } from "../tools.ts"
|
|
6
|
+
|
|
7
|
+
import { adoSettings } from "../cases/ado.ts"
|
|
8
|
+
import { http } from "../cases/http.ts"
|
|
9
|
+
import type { TestCase, WriteBack } from "../cases/types.ts"
|
|
10
|
+
import type { SourcesConfig } from "../config.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which automated test belongs to which manual test case. Every coding job for a test case records its test in
|
|
14
|
+
* .forgeqe/test-cases.json in the repository (committed with the tests, so the team shares it), and a test case
|
|
15
|
+
* from Azure DevOps also gets its Associated Automation fields set, so the test plan shows it as automated.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const MAP_FILE = path.join(".forgeqe", "test-cases.json")
|
|
19
|
+
const mapFile = (repo: string) => path.join(projectDir(repo), path.basename(MAP_FILE))
|
|
20
|
+
|
|
21
|
+
export type MappingStatus = "automated" | "known_defect" | "needs_attention"
|
|
22
|
+
|
|
23
|
+
export type Mapping = {
|
|
24
|
+
caseId: string
|
|
25
|
+
title: string
|
|
26
|
+
source: string
|
|
27
|
+
url?: string
|
|
28
|
+
status: MappingStatus
|
|
29
|
+
testFile?: string
|
|
30
|
+
testName?: string
|
|
31
|
+
command?: string
|
|
32
|
+
/** The bug a known-defect test waits on. */
|
|
33
|
+
bug?: { label: string; url?: string }
|
|
34
|
+
/** The needs-attention folder, for an unfinished test. */
|
|
35
|
+
attention?: string
|
|
36
|
+
updatedAt: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type MapFile = { version: 1; cases: Record<string, Mapping> }
|
|
40
|
+
|
|
41
|
+
export function readMappings(repo: string): MapFile {
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(readFileSync(mapFile(repo), "utf8")) as MapFile
|
|
44
|
+
return { version: 1, cases: data?.cases && typeof data.cases === "object" ? data.cases : {} }
|
|
45
|
+
} catch {
|
|
46
|
+
return { version: 1, cases: {} }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Record (or replace) a test case's test in the repository's mapping file. */
|
|
51
|
+
export function recordMapping(repo: string, m: Omit<Mapping, "updatedAt">): void {
|
|
52
|
+
const data = readMappings(repo)
|
|
53
|
+
data.cases[m.caseId] = { ...m, updatedAt: new Date().toISOString() }
|
|
54
|
+
const sorted: MapFile = { version: 1, cases: Object.fromEntries(Object.entries(data.cases).sort(([a], [b]) => a.localeCompare(b))) }
|
|
55
|
+
mkdirSync(projectDir(repo), { recursive: true })
|
|
56
|
+
writeFileSync(mapFile(repo), `${JSON.stringify(sorted, null, 2)}\n`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The test framework a command runs, for Azure DevOps' AutomatedTestType. */
|
|
60
|
+
export function frameworkOf(command: string | undefined, testFile: string | undefined): string {
|
|
61
|
+
const c = `${command || ""} ${testFile || ""}`.toLowerCase()
|
|
62
|
+
if (c.includes("playwright")) return "Playwright"
|
|
63
|
+
if (c.includes("cypress")) return "Cypress"
|
|
64
|
+
if (c.includes("pytest") || c.endsWith(".py")) return "pytest"
|
|
65
|
+
if (c.includes("dotnet test") || /\.cs\b/.test(c)) return "NUnit/xUnit (dotnet test)"
|
|
66
|
+
if (c.includes("vitest")) return "Vitest"
|
|
67
|
+
if (c.includes("jest")) return "Jest"
|
|
68
|
+
if (c.includes("mvn") || c.includes("gradle")) return "JUnit"
|
|
69
|
+
return "Automated test"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A stable GUID for a test, from its name and file: AutomatedTestId must be one, and the same test keeps the same id. */
|
|
73
|
+
export function testGuid(name: string, file: string): string {
|
|
74
|
+
const h = createHash("sha1").update(`${file}::${name}`).digest("hex")
|
|
75
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-${((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16)}${h.slice(18, 20)}-${h.slice(20, 32)}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Set a Test Case work item's Associated Automation: the test's name, its file, its framework, and a stable id, with
|
|
80
|
+
* Automation status Automated. One write-back line, with an error when Azure DevOps refused it.
|
|
81
|
+
*/
|
|
82
|
+
export async function associateAdoAutomation(cfg: SourcesConfig, tc: TestCase, test: { name: string; file: string; command?: string }): Promise<WriteBack> {
|
|
83
|
+
const id = Number(tc.ref?.workItem)
|
|
84
|
+
if (!id) return { source: "ado", caseId: tc.id, text: `${tc.id}: automation not associated`, error: "the test case has no work item id" }
|
|
85
|
+
try {
|
|
86
|
+
const { org, project, base } = adoSettings(cfg)
|
|
87
|
+
const where = project ? base : typeof tc.ref?.project === "string" ? `${org}/${encodeURIComponent(tc.ref.project)}` : org
|
|
88
|
+
const ops = [
|
|
89
|
+
["Microsoft.VSTS.TCM.AutomatedTestName", test.name],
|
|
90
|
+
["Microsoft.VSTS.TCM.AutomatedTestStorage", test.file],
|
|
91
|
+
["Microsoft.VSTS.TCM.AutomatedTestType", frameworkOf(test.command, test.file)],
|
|
92
|
+
["Microsoft.VSTS.TCM.AutomatedTestId", testGuid(test.name, test.file)],
|
|
93
|
+
["Microsoft.VSTS.TCM.AutomationStatus", "Automated"],
|
|
94
|
+
].map(([field, value]) => ({ op: "add", path: `/fields/${field}`, value }))
|
|
95
|
+
await http(`${where}/_apis/wit/workitems/${id}?api-version=7.1`, { auth: adoSettings(cfg).auth, method: "PATCH", body: ops, contentType: "application/json-patch+json" })
|
|
96
|
+
return { source: "ado", caseId: tc.id, text: `${tc.id}: associated with the automated test ${test.name}`, ...(tc.url ? { url: tc.url } : {}) }
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return { source: "ado", caseId: tc.id, text: `${tc.id}: automation not associated`, error: (err as Error).message }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import type { ChangedFile } from "./prompt.ts"
|
|
7
|
+
|
|
8
|
+
const KEEP_BYTES = 5_000_000
|
|
9
|
+
|
|
10
|
+
type Entry = { status: string; hash: string; content?: Buffer }
|
|
11
|
+
|
|
12
|
+
/** The repository's uncommitted state before a job: what the user had changed, and ForgeQE's own files. */
|
|
13
|
+
export type WorkTree = { git: boolean; repo: string; own: string[]; entries: Map<string, Entry> }
|
|
14
|
+
|
|
15
|
+
/** ForgeQE's files in the repository, relative to it: config, run folders, and page memory. Never part of a job's change. */
|
|
16
|
+
export function ownPaths(repo: string, dirs: string[]): string[] {
|
|
17
|
+
const inside = dirs.map((d) => path.relative(repo, path.resolve(d))).filter((r) => r && !r.startsWith("..") && !path.isAbsolute(r))
|
|
18
|
+
return [...new Set([".forgeqe", "forgeqe.yaml", ".webpilot", "webpilot.yaml", "llms.json", ...inside].map((p) => p.split(path.sep).join("/")))]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the coding agent may not touch, as the agent runtime matches it: edit patterns relative to the git worktree root (or
|
|
23
|
+
* the filesystem root outside git), shell patterns as the agent would write the paths.
|
|
24
|
+
*/
|
|
25
|
+
export function protectPatterns(repo: string, own: string[], outside: string[]): { edit: string[]; bash: string[] } {
|
|
26
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: repo, encoding: "utf8" })
|
|
27
|
+
const base = top.status === 0 && top.stdout.trim() ? path.resolve(top.stdout.trim()) : path.parse(repo).root
|
|
28
|
+
const abs = [...own.map((p) => path.join(repo, p)), ...outside]
|
|
29
|
+
const posix = (p: string) => p.split(path.sep).join("/")
|
|
30
|
+
return { edit: abs.map((p) => posix(path.relative(base, p))), bash: [...own, ...outside.map(posix)] }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isOwn(own: string[], rel: string): boolean {
|
|
34
|
+
return own.some((o) => rel === o || rel.startsWith(`${o}/`))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function gitStatus(repo: string): Map<string, string> | null {
|
|
38
|
+
const r = spawnSync("git", ["status", "--porcelain=v1", "-z", "-uall", "--no-renames"], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
|
|
39
|
+
if (r.status !== 0) return null
|
|
40
|
+
const out = new Map<string, string>()
|
|
41
|
+
for (const rec of r.stdout.split("\0")) if (rec.length > 3) out.set(rec.slice(3), rec.slice(0, 2))
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function read(repo: string, rel: string): Buffer | null {
|
|
46
|
+
try {
|
|
47
|
+
const file = path.join(repo, rel)
|
|
48
|
+
return statSync(file).isFile() ? readFileSync(file) : null
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hashOf = (b: Buffer | null) => (b ? createHash("sha1").update(b).digest("hex") : "-")
|
|
55
|
+
|
|
56
|
+
export function snapshot(repo: string, own: string[]): WorkTree {
|
|
57
|
+
const entries = new Map<string, Entry>()
|
|
58
|
+
const status = gitStatus(repo)
|
|
59
|
+
const add = (rel: string, st: string) => {
|
|
60
|
+
const content = read(repo, rel)
|
|
61
|
+
entries.set(rel, { status: st, hash: hashOf(content), content: content && content.length <= KEEP_BYTES ? content : undefined })
|
|
62
|
+
}
|
|
63
|
+
for (const [rel, st] of status || []) if (!isOwn(own, rel)) add(rel, st)
|
|
64
|
+
for (const rel of own) if (!entries.has(rel) && read(repo, rel)) add(rel, "own")
|
|
65
|
+
return { git: status !== null, repo, own, entries }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Uncommitted paths the user already had before the job, ForgeQE's own files left out. */
|
|
69
|
+
export function preexisting(tree: WorkTree): string[] {
|
|
70
|
+
return [...tree.entries].filter(([rel, e]) => e.status !== "own" && !isOwn(tree.own, rel)).map(([rel]) => rel)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Paths the job created, changed, reverted, or deleted since the snapshot; null outside git. */
|
|
74
|
+
export function jobChanges(tree: WorkTree): string[] | null {
|
|
75
|
+
const now = tree.git ? gitStatus(tree.repo) : null
|
|
76
|
+
if (!now) return null
|
|
77
|
+
const paths = new Set([...now.keys(), ...preexisting(tree)])
|
|
78
|
+
return [...paths].filter((rel) => {
|
|
79
|
+
if (isOwn(tree.own, rel)) return false
|
|
80
|
+
const was = tree.entries.get(rel)
|
|
81
|
+
return !was || !now.has(rel) || was.hash !== hashOf(read(tree.repo, rel))
|
|
82
|
+
}).sort()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Put back what the job must not take away: ForgeQE's files (removed or changed: the shell rules that protect them are
|
|
87
|
+
* only guard rails), the user's untracked files, and the user's uncommitted edits reverted to the committed version.
|
|
88
|
+
* Edits the job made on top of the user's are kept.
|
|
89
|
+
*/
|
|
90
|
+
export function restoreRemoved(tree: WorkTree, only: "all" | "own" = "all"): string[] {
|
|
91
|
+
const now = tree.git && only === "all" ? gitStatus(tree.repo) : null
|
|
92
|
+
const back: string[] = []
|
|
93
|
+
for (const [rel, e] of tree.entries) {
|
|
94
|
+
if (!e.content) continue
|
|
95
|
+
if (only === "own" && e.status !== "own") continue
|
|
96
|
+
const current = read(tree.repo, rel)
|
|
97
|
+
const gone = current === null
|
|
98
|
+
const ownChanged = e.status === "own" && !gone && hashOf(current) !== e.hash
|
|
99
|
+
const reverted = now !== null && e.status !== "own" && e.status !== "??" && e.status.includes("M") && !now.has(rel)
|
|
100
|
+
if (!gone && !reverted && !ownChanged) continue
|
|
101
|
+
const file = path.join(tree.repo, rel)
|
|
102
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
103
|
+
writeFileSync(file, e.content)
|
|
104
|
+
back.push(rel)
|
|
105
|
+
}
|
|
106
|
+
return back
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const INLINE_FILE_BYTES = 12_000
|
|
110
|
+
const INLINE_TOTAL_BYTES = 60_000
|
|
111
|
+
|
|
112
|
+
/** The job's changed files for a fresh session's brief: contents inline while they are small, paths only past that. */
|
|
113
|
+
export function changedFiles(repo: string, paths: string[]): ChangedFile[] {
|
|
114
|
+
let total = 0
|
|
115
|
+
return paths.map((rel) => {
|
|
116
|
+
try {
|
|
117
|
+
const text = readFileSync(path.join(repo, rel), "utf8")
|
|
118
|
+
if (text.length > INLINE_FILE_BYTES || total + text.length > INLINE_TOTAL_BYTES) return { path: rel }
|
|
119
|
+
total += text.length
|
|
120
|
+
return { path: rel, content: text }
|
|
121
|
+
} catch {
|
|
122
|
+
return { path: rel }
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The current contents of the given files (null for a file that does not exist), to put back later. */
|
|
128
|
+
export function snapshotFiles(repo: string, paths: string[]): Map<string, Buffer | null> {
|
|
129
|
+
return new Map(paths.map((rel) => [rel, read(repo, rel)]))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Put the files back as `files` had them. A file changed since that is not in it goes back to how it was before the
|
|
134
|
+
* job (the user's version, or the committed one), or is removed when the job created it after the snapshot.
|
|
135
|
+
*/
|
|
136
|
+
export function restoreSnapshot(repo: string, tree: WorkTree, files: Map<string, Buffer | null>): void {
|
|
137
|
+
for (const [rel, content] of files) {
|
|
138
|
+
const file = path.join(repo, rel)
|
|
139
|
+
if (content === null) rmSync(file, { force: true })
|
|
140
|
+
else {
|
|
141
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
142
|
+
writeFileSync(file, content)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const rel of jobChanges(tree) || []) {
|
|
146
|
+
if (files.has(rel)) continue
|
|
147
|
+
const file = path.join(repo, rel)
|
|
148
|
+
const was = tree.entries.get(rel)
|
|
149
|
+
if (was?.content) writeFileSync(file, was.content)
|
|
150
|
+
else if (spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: repo }).status === 0) spawnSync("git", ["checkout", "--", rel], { cwd: repo })
|
|
151
|
+
else rmSync(file, { force: true })
|
|
152
|
+
}
|
|
153
|
+
}
|