@capagents/forgeqe 1.1.9 → 1.2.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 +30 -1
- package/docs/user-guide.html +28 -2
- package/package.json +1 -1
- package/prompts/brain.md +7 -0
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- package/prompts/test-rules.md +2 -0
- package/py/forgeqe_engine/api_step.py +130 -0
- package/py/forgeqe_engine/capture.py +53 -2
- package/py/forgeqe_engine/memory.py +11 -0
- package/py/forgeqe_engine/service.py +22 -2
- package/src/api/fromSteps.ts +111 -0
- package/src/cases/quality.ts +3 -1
- package/src/code/coverage.ts +117 -0
- package/src/code/exchange.ts +23 -2
- package/src/code/graph/graph.ts +30 -3
- package/src/code/handoff.ts +20 -0
- package/src/code/loop.ts +67 -6
- package/src/code/notes.ts +76 -0
- package/src/code/prompt.ts +52 -3
- package/src/code/replay.ts +33 -0
- package/src/code/review.ts +13 -0
- package/src/mcp/commands.ts +76 -0
- package/src/mcp/tools.ts +69 -2
- package/src/run.ts +10 -0
- package/src/suite/report.ts +22 -4
- package/src/suite/run.ts +1 -0
- package/src/summary.ts +5 -0
- package/src/tui/App.tsx +1 -0
- package/src/tui/transcript.tsx +5 -4
- package/src/types.ts +4 -0
package/src/cases/quality.ts
CHANGED
|
@@ -54,7 +54,9 @@ export function checkCase(tc: TestCase): CaseFinding[] {
|
|
|
54
54
|
}
|
|
55
55
|
const withExpected = steps.filter((s) => s.expected?.trim())
|
|
56
56
|
if (!withExpected.length) {
|
|
57
|
-
|
|
57
|
+
// It still runs: each step is done and verified, so the case passes when every step can be done. What it does not
|
|
58
|
+
// have is a result to check, so it is worth fixing, not a reason to refuse it.
|
|
59
|
+
out.push({ code: "no_expected_results", severity: "warning", message: "No step has an expected result: the run passes when every step can be done, and checks no result the page should show.", suggestion: "Give at least the steps that matter an expected result the page shows (a message, a value, a page)." })
|
|
58
60
|
}
|
|
59
61
|
steps.forEach((s, i) => {
|
|
60
62
|
const n = i + 1
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml"
|
|
5
|
+
|
|
6
|
+
import { readPages } from "../memory.ts"
|
|
7
|
+
import { membersLocating, qualified, sameElement, testsReachingMembers, type CodeGraph, type Located } from "./graph/graph.ts"
|
|
8
|
+
import { fromMemory, type LocatorRef } from "./graph/locators.ts"
|
|
9
|
+
import type { Check, Locator, Step } from "./handoff.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the repository's existing tests already cover of a passed run, measured rather than guessed: each step and
|
|
13
|
+
* check the run made on an element is matched, through page memory, to the page-object members that locate that
|
|
14
|
+
* element, and the code graph says which tests reach those members. From that, the smallest safe change: reuse a test
|
|
15
|
+
* that covers everything, extend one that covers part, write a new one, or ask when the evidence does not settle it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A step or check of the run that acted on or read an element, and the repository members that locate it. */
|
|
19
|
+
export type CoverageItem = { label: string; kind: "step" | "check"; members: string[] }
|
|
20
|
+
|
|
21
|
+
/** An existing test and which of the run's items (by index) it reaches. */
|
|
22
|
+
export type Candidate = { test: string; file: string; line: number; covered: number[]; missing: number[]; share: number }
|
|
23
|
+
|
|
24
|
+
export type Mode = "new" | "extend" | "reuse" | "update"
|
|
25
|
+
|
|
26
|
+
/** `ask`: the evidence fits more than one way, so the person chooses (the options, each with what it changes). */
|
|
27
|
+
export type Decision = { mode: Mode; ask: boolean; target?: Candidate; why: string }
|
|
28
|
+
|
|
29
|
+
export type Coverage = { items: CoverageItem[]; candidates: Candidate[]; decision: Decision }
|
|
30
|
+
|
|
31
|
+
/** A test covering at least this share of the run's items is worth extending rather than duplicating. */
|
|
32
|
+
export const EXTEND_FROM = 0.4
|
|
33
|
+
/** Another test this close to the best one makes the choice the person's. */
|
|
34
|
+
const RIVAL = 0.75
|
|
35
|
+
|
|
36
|
+
type RunJson = { final_url?: string; memory?: { dir?: string } }
|
|
37
|
+
|
|
38
|
+
function readJson<T>(file: string): T | null {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(file, "utf8")) as T
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const quote = (t?: string) => (t ? `"${t.replace(/\s+/g, " ").trim().slice(0, 60)}"` : "")
|
|
47
|
+
|
|
48
|
+
/** "click "Toggle Input Box 2"", "input "Name"", "check "Interactive SVG Pie Chart"". */
|
|
49
|
+
function stepLabel(s: Step & { label?: string; text?: string }): string {
|
|
50
|
+
return `${s.op} ${quote(s.label || s.text) || "(an element)"}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function measureCoverage(runDir: string, graph: CodeGraph, memoryDir?: string): Coverage {
|
|
54
|
+
const run = readJson<RunJson>(path.join(runDir, "run.json")) || {}
|
|
55
|
+
const file = path.join(runDir, "workflow.yaml")
|
|
56
|
+
const workflow = existsSync(file) ? ((YAML.parse(readFileSync(file, "utf8")) || {}) as { steps?: Step[]; checks?: Check[] }) : {}
|
|
57
|
+
const memDir = run.memory?.dir || memoryDir
|
|
58
|
+
const pages = memDir ? readPages(memDir) : []
|
|
59
|
+
const located: Located[][] = []
|
|
60
|
+
const items: CoverageItem[] = []
|
|
61
|
+
const add = (label: string, kind: CoverageItem["kind"], locators: Locator[] | undefined, page: string | undefined) => {
|
|
62
|
+
const refs = (locators || []).map((l) => fromMemory(l.type, l.value)).filter((r): r is LocatorRef => Boolean(r))
|
|
63
|
+
if (!refs.length) return
|
|
64
|
+
const same = sameElement(pages, refs, page)
|
|
65
|
+
const matches = membersLocating(graph, same.refs).filter((m) => m.how !== "param")
|
|
66
|
+
located.push(matches.map((m) => m.at))
|
|
67
|
+
items.push({ label, kind, members: [...new Set(matches.map((m) => qualified(m.at)))] })
|
|
68
|
+
}
|
|
69
|
+
for (const s of workflow.steps || []) if (["click", "input", "select"].includes(s.op || "")) add(stepLabel(s), "step", s.locators, s.page || s.page_url)
|
|
70
|
+
for (const c of workflow.checks || []) if (c?.text?.trim()) add(`check ${quote(c.expected || c.text)}`, "check", c.element?.locators, c.url || run.final_url)
|
|
71
|
+
|
|
72
|
+
// Each test, with the items it reaches.
|
|
73
|
+
const byTest = new Map<string, { at: Located; covered: Set<number> }>()
|
|
74
|
+
located.forEach((members, i) => {
|
|
75
|
+
if (!members.length) return
|
|
76
|
+
for (const t of testsReachingMembers(graph, members)) {
|
|
77
|
+
const key = `${t.file}#${qualified(t)}`
|
|
78
|
+
const entry = byTest.get(key) || { at: t, covered: new Set<number>() }
|
|
79
|
+
entry.covered.add(i)
|
|
80
|
+
byTest.set(key, entry)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
// Only items the repository locates can be measured: a step no page-object member locates may still be done by a
|
|
84
|
+
// test in its own way, so it is unknown, not missing.
|
|
85
|
+
const measurable = items.flatMap((it, i) => (it.members.length ? [i] : []))
|
|
86
|
+
const candidates = [...byTest.values()]
|
|
87
|
+
.map(({ at, covered }) => ({ test: qualified(at), file: at.file, line: at.member.line, covered: [...covered].sort((a, b) => a - b), missing: measurable.filter((i) => !covered.has(i)), share: measurable.length ? covered.size / measurable.length : 0 }))
|
|
88
|
+
.sort((a, b) => b.covered.length - a.covered.length || a.missing.length - b.missing.length)
|
|
89
|
+
return { items, candidates, decision: decide(items, candidates) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The smallest safe change the evidence supports, and whether it is clear enough to go ahead without asking. */
|
|
93
|
+
export function decide(items: CoverageItem[], candidates: Candidate[]): Decision {
|
|
94
|
+
const best = candidates[0]
|
|
95
|
+
const n = items.filter((i) => i.members.length).length
|
|
96
|
+
if (!n || !best || best.share < EXTEND_FROM) {
|
|
97
|
+
return { mode: "new", ask: false, why: !n ? "no element this run used is located by the repository's page objects yet" : best ? `no existing test covers more than ${best.covered.length} of the run's ${n} steps and checks` : "no existing test reaches the elements this run used" }
|
|
98
|
+
}
|
|
99
|
+
const rival = candidates[1]
|
|
100
|
+
if (rival && rival.share >= EXTEND_FROM && rival.covered.length >= RIVAL * best.covered.length) {
|
|
101
|
+
return { mode: best.missing.length ? "extend" : "reuse", ask: true, target: best, why: `${candidates.filter((c) => c.share >= EXTEND_FROM).length} existing tests each cover most of this flow` }
|
|
102
|
+
}
|
|
103
|
+
if (!best.missing.length) return { mode: "reuse", ask: false, target: best, why: `${best.test} already covers all ${n} steps and checks` }
|
|
104
|
+
return { mode: "extend", ask: false, target: best, why: `${best.test} covers ${best.covered.length} of ${n}; only ${best.missing.length} ${best.missing.length === 1 ? "is" : "are"} missing` }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The evidence as card lines: each close test, what it covers, and what it lacks. */
|
|
108
|
+
export function coverageLines(c: Coverage, max = 3): string[] {
|
|
109
|
+
const n = c.items.filter((i) => i.members.length).length
|
|
110
|
+
const lines = c.candidates.slice(0, max).map((t) => {
|
|
111
|
+
const missing = t.missing.map((i) => c.items[i]!.label)
|
|
112
|
+
return `${t.share >= EXTEND_FROM ? "◐" : "·"} ${t.test} (${t.file}:${t.line}) covers ${t.covered.length} of ${n}${missing.length ? `; missing: ${missing.slice(0, 4).join(", ")}${missing.length > 4 ? ` +${missing.length - 4} more` : ""}` : ""}`
|
|
113
|
+
})
|
|
114
|
+
const unlocated = c.items.filter((i) => !i.members.length)
|
|
115
|
+
if (unlocated.length) lines.push(`· not measured: ${unlocated.length} of ${c.items.length} steps ${unlocated.length === 1 ? "has" : "have"} no page-object member (${[...new Set(unlocated.map((i) => i.label))].slice(0, 3).join(", ")}); a test may do ${unlocated.length === 1 ? "it" : "them"} its own way`)
|
|
116
|
+
return lines
|
|
117
|
+
}
|
package/src/code/exchange.ts
CHANGED
|
@@ -15,6 +15,27 @@ const clip = (t: string, n: number) => {
|
|
|
15
15
|
return s.length <= n ? s : `${s.slice(0, n - 1)}…`
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The text without the domain statement the prompts ask every agent to open with ("Assumed industry: …; no
|
|
20
|
+
* regulated-industry obligations applied."): right for the record, but in the exchange it hides what was done.
|
|
21
|
+
*/
|
|
22
|
+
export function withoutDomainNote(text: string): string {
|
|
23
|
+
const note = /^(Assumed (industry|domain)\b|No regulated-industry|Industry assumed\b)/i
|
|
24
|
+
const kept = text
|
|
25
|
+
.split(/(?<=[.!?])\s+/)
|
|
26
|
+
.map((sentence) => {
|
|
27
|
+
const t = sentence.trim()
|
|
28
|
+
if (!note.test(t)) return t
|
|
29
|
+
// The note, then what the agent actually says after it in the same sentence: keep that part.
|
|
30
|
+
const rest = t.split(";").slice(1).map((p) => p.trim()).filter((p) => p && !/^no regulated-industry/i.test(p)).join("; ")
|
|
31
|
+
return rest ? rest[0]!.toUpperCase() + rest.slice(1) : ""
|
|
32
|
+
})
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
// An "Approved." that only led into the domain note says nothing the title does not.
|
|
35
|
+
const out = kept.join(" ").replace(/^Approved\.\s*$/i, "").trim()
|
|
36
|
+
return out || text.trim()
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
/** "✗ Does not test the goal · tests/Practice.cs: the pie chart is never checked", then the fix under it. */
|
|
19
40
|
export function reviewCard(e: Review): Card {
|
|
20
41
|
const correctness = e.issues.filter((i) => i.kind !== "standards").length
|
|
@@ -25,7 +46,7 @@ export function reviewCard(e: Review): Card {
|
|
|
25
46
|
title: e.approved ? (standards ? "Reviewer approved the test, with coding-standard notes" : "Reviewer approved the test") : "Reviewer asked for changes",
|
|
26
47
|
stats: [`attempt ${e.attempt} of ${e.max}`, count].filter(Boolean).join(" · "),
|
|
27
48
|
body: [
|
|
28
|
-
...(e.summary ? [clip(e.summary, 400)] : []),
|
|
49
|
+
...(e.summary ? [clip(withoutDomainNote(e.summary), 400)] : []),
|
|
29
50
|
...(e.issues.length ? [""] : []),
|
|
30
51
|
...e.issues.flatMap((i) => [
|
|
31
52
|
// ✗ marks what makes the test wrong, ~ how it is written: the colours follow the mark.
|
|
@@ -49,5 +70,5 @@ export function attemptCard(e: Attempt): Card {
|
|
|
49
70
|
/** "Scribe (attempt 2): Moved the values into PracticePage · changed tests/Practice.cs, pages/PracticePage.cs". */
|
|
50
71
|
export function answerText(e: Answer): string {
|
|
51
72
|
const files = e.changed.length ? `changed ${e.changed.slice(0, 4).join(", ")}${e.changed.length > 4 ? ` +${e.changed.length - 4} more` : ""}` : ""
|
|
52
|
-
return `Scribe (attempt ${e.attempt}): ${[e.notes ? clip(e.notes, 300) : "", files].filter(Boolean).join(" · ")}`
|
|
73
|
+
return `Scribe (attempt ${e.attempt}): ${[e.notes ? clip(withoutDomainNote(e.notes), 300) : "", files].filter(Boolean).join(" · ")}`
|
|
53
74
|
}
|
package/src/code/graph/graph.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function allMembers(g: CodeGraph): Located[] {
|
|
|
90
90
|
return g.files.flatMap((f) => [...f.classes.flatMap((cls) => cls.members.map((member) => ({ file: f.file, cls, member }))), ...f.functions.map((member) => ({ file: f.file, member }))])
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
const qualified = (l: Located) => (l.cls ? `${l.cls.name}.${l.member.name}` : l.member.name)
|
|
93
|
+
export const qualified = (l: Located) => (l.cls ? `${l.cls.name}.${l.member.name}` : l.member.name)
|
|
94
94
|
const where = (file: string, line: number) => `${file}:${line}`
|
|
95
95
|
|
|
96
96
|
/** Words of an identifier or phrase: SignInAsync → sign, in, async; "Sign in" → sign, in. */
|
|
@@ -397,14 +397,41 @@ export function describeReferences(target: string, r: ReturnType<typeof referenc
|
|
|
397
397
|
*/
|
|
398
398
|
export function testsReaching(g: CodeGraph, changed: string[]): { test: Located; why: string }[] {
|
|
399
399
|
const files = new Set(changed.map((f) => f.replace(/^\.\//, "")))
|
|
400
|
-
const classes = new Set(g.files.flatMap((f) => f.classes.map((c) => c.name)))
|
|
401
|
-
const byFile = new Map(g.files.map((f) => [f.file, f]))
|
|
402
400
|
const all = allMembers(g)
|
|
403
401
|
// Reached members, as Class.member, with why.
|
|
404
402
|
const reached = new Map<string, string>()
|
|
405
403
|
for (const l of all) if (files.has(l.file)) reached.set(qualified(l), `in ${l.file}`)
|
|
406
404
|
const out = new Map<Located, string>()
|
|
407
405
|
for (const l of all) if (files.has(l.file) && l.member.kind === "test") out.set(l, `is in ${l.file}`)
|
|
406
|
+
return reachTests(g, reached, out)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The tests that reach any of these members (page-object fields or methods): directly, or through the page methods
|
|
411
|
+
* that use a field and the calls that lead to them. What an existing test already exercises of a run's elements.
|
|
412
|
+
*/
|
|
413
|
+
export function testsReachingMembers(g: CodeGraph, members: Located[]): Located[] {
|
|
414
|
+
const reached = new Map<string, string>()
|
|
415
|
+
for (const m of members) {
|
|
416
|
+
reached.set(qualified(m), "located")
|
|
417
|
+
// A field is reached through the methods that read it (LoginButton → SignInAsync), which is what a test calls.
|
|
418
|
+
for (const user of usersOf(g, m)) if (!reached.has(user)) reached.set(user, `uses ${qualified(m)}`)
|
|
419
|
+
}
|
|
420
|
+
const out = new Map<Located, string>()
|
|
421
|
+
const all = allMembers(g)
|
|
422
|
+
// A test that reads the member as a property reaches it directly: in its own class, or through a variable of the
|
|
423
|
+
// member's page-object class (Expect(practicePage.AjaxStatus) with practicePage a PracticePage). Reads are not calls.
|
|
424
|
+
const byFile = new Map(g.files.map((f) => [f.file, f]))
|
|
425
|
+
const holds = (l: Located, cls: string) => l.cls?.name === cls || Object.values(byFile.get(l.file)?.bindings || {}).some((b) => lineage(g, b).includes(cls))
|
|
426
|
+
for (const l of all) if (l.member.kind === "test" && members.some((m) => m.cls && l.member.idents.includes(m.member.name) && holds(l, m.cls.name))) out.set(l, "reads it")
|
|
427
|
+
return reachTests(g, reached, out).map((r) => r.test)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Grows `reached` along calls, up to five levels, and adds each test that calls a reached member to `out`. */
|
|
431
|
+
function reachTests(g: CodeGraph, reached: Map<string, string>, out: Map<Located, string>): { test: Located; why: string }[] {
|
|
432
|
+
const classes = new Set(g.files.flatMap((f) => f.classes.map((c) => c.name)))
|
|
433
|
+
const byFile = new Map(g.files.map((f) => [f.file, f]))
|
|
434
|
+
const all = allMembers(g)
|
|
408
435
|
for (let round = 0; round < 5; round++) {
|
|
409
436
|
let grew = false
|
|
410
437
|
for (const l of all) {
|
package/src/code/handoff.ts
CHANGED
|
@@ -43,6 +43,16 @@ export type Step = {
|
|
|
43
43
|
scripted?: boolean
|
|
44
44
|
/** click: the control sat in an overlay (cookie banner, sign-in prompt, dialog) that this click dismissed. */
|
|
45
45
|
overlay?: { kind?: string; name?: string; id?: string }
|
|
46
|
+
/** api: an HTTP call made in the browser's session, and what the server answered when the run made it. */
|
|
47
|
+
method?: string
|
|
48
|
+
body?: string
|
|
49
|
+
content_type?: string
|
|
50
|
+
/** api: where the CSRF token came from: the "cookie", or the page's "form". */
|
|
51
|
+
csrf?: string
|
|
52
|
+
status?: number
|
|
53
|
+
final_url?: string
|
|
54
|
+
redirected?: boolean
|
|
55
|
+
snippet?: string
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
/** An element the engine found on the live page, with its locators (verified ones first). */
|
|
@@ -174,6 +184,10 @@ function stepLines(step: Step, n: number): string[] {
|
|
|
174
184
|
break
|
|
175
185
|
case "press":
|
|
176
186
|
return [`${n}. press ${step.keys}`]
|
|
187
|
+
case "api":
|
|
188
|
+
return [
|
|
189
|
+
`${n}. API ${step.method || "GET"} ${step.url}${step.body ? ` with ${step.content_type?.includes("json") ? "JSON" : "form"} body ${quote(step.body.slice(0, 300))}` : ""}${step.csrf ? `, CSRF token from the ${step.csrf === "form" ? "page's form" : "csrftoken cookie"}` : ""}: the server answered ${step.status ?? "?"}${step.redirected && step.final_url ? `, ending at ${step.final_url}` : ""}${step.snippet ? `; response starts: ${quote(step.snippet.slice(0, 160))}` : ""}`,
|
|
190
|
+
]
|
|
177
191
|
case "evaluate":
|
|
178
192
|
return step.scripted
|
|
179
193
|
? [
|
|
@@ -558,6 +572,12 @@ export function buildHandoff(runDir: string, memoryDir?: string, opts: { replay?
|
|
|
558
572
|
"",
|
|
559
573
|
)
|
|
560
574
|
}
|
|
575
|
+
if (steps.some((s) => s.op === "api")) {
|
|
576
|
+
md.push(
|
|
577
|
+
"Steps marked API are HTTP calls the run made in the browser's own session (its cookies went with each call, so a login and a cart carry over between the API and the UI steps). Make each one in the test the same way: through the framework's API request context that shares the browser's cookies (Playwright: page.request in TypeScript, Page.APIRequest in .NET, page.request in Python and Java), with the same method, URL, and body, reading the CSRF token where the step says (the page's form, or the csrftoken cookie) and sending it as the site expects. Assert the status the run got. Do not replace an API step with clicks, or a UI step with an API call.",
|
|
578
|
+
"",
|
|
579
|
+
)
|
|
580
|
+
}
|
|
561
581
|
const slices = stepSlices(steps, opts.replay)
|
|
562
582
|
if (slices.size) {
|
|
563
583
|
md.push(
|
package/src/code/loop.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type AgentReport,
|
|
40
40
|
type ExistingTest,
|
|
41
41
|
type ReviewIssue,
|
|
42
|
+
type TestTarget,
|
|
42
43
|
} from "./prompt.ts"
|
|
43
44
|
import { openReviewer, type Reviewer } from "./review.ts"
|
|
44
45
|
import { runTestCommand } from "./testrun.ts"
|
|
@@ -81,6 +82,44 @@ function attemptLine(attempt: number, outcome: string, detail: string, notes?: s
|
|
|
81
82
|
return [`${attempt}: ${outcome}`, detail ? `: ${clip(detail.replace(/\s+/g, " ").trim(), 240)}` : "", notes ? ` (Scribe: ${clip(notes.replace(/\s+/g, " ").trim(), 240)})` : ""].join("")
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Why a test failed, in up to three lines a person reads: the assertion or exception and its expected and received
|
|
87
|
+
* values, not the runner's summary ("Failed PracticePage_Flow [19 s]", "1 failed"). Knows dotnet test's "Error
|
|
88
|
+
* Message:" block, pytest's "E " lines, and the "Error:"/"Expected"/"Received" lines of Playwright, Jest, and JUnit.
|
|
89
|
+
*/
|
|
90
|
+
export function failureReason(output: string): string[] {
|
|
91
|
+
const lines = output.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").trimEnd())
|
|
92
|
+
const tidy = (l: string) => l.trim().replace(/\s+/g, " ").slice(0, 300)
|
|
93
|
+
// dotnet test (NUnit, xUnit, MSTest): the lines between "Error Message:" and "Stack Trace:".
|
|
94
|
+
const message = lines.findIndex((l) => /^\s*Error Message:\s*$/.test(l))
|
|
95
|
+
if (message >= 0) {
|
|
96
|
+
const out: string[] = []
|
|
97
|
+
for (const l of lines.slice(message + 1)) {
|
|
98
|
+
if (/^\s*(Stack Trace:|Standard Output Messages:|Call log:)/.test(l)) break
|
|
99
|
+
if (l.trim()) out.push(tidy(l))
|
|
100
|
+
if (out.length === 3) break
|
|
101
|
+
}
|
|
102
|
+
if (out.length) return out
|
|
103
|
+
}
|
|
104
|
+
// pytest: the "E " lines under the failing line.
|
|
105
|
+
const pytest = lines.filter((l) => /^E\s{2,}\S/.test(l)).map((l) => tidy(l.slice(1)))
|
|
106
|
+
if (pytest.length) return pytest.slice(0, 3)
|
|
107
|
+
// Playwright, Jest, JUnit and the rest: the first error line and the expected and received values after it.
|
|
108
|
+
const summary = /^\s*(Failed\s+\S+\s+\[|Failed!|Total tests|\d+ (failed|passed)|Tests? (run|failed)|FAILED\b|=+ )/
|
|
109
|
+
// Source excerpts ("3 | expect(x).toBe(y)", "> 12 | …") quote the code, not the failure: they are passed over.
|
|
110
|
+
const excerpt = /^\s*>?\s*\d+\s*\|/
|
|
111
|
+
const first = lines.findIndex((l) => !summary.test(l) && !excerpt.test(l) && !/^\s*at\s/.test(l) && /(\w*(Error|Exception)\b[:\s]|expect\(|AssertionError|Assert\.|timed? ?out|Timeout \d+ms)/i.test(l))
|
|
112
|
+
if (first >= 0) {
|
|
113
|
+
const out = [tidy(lines[first]!)]
|
|
114
|
+
for (const l of lines.slice(first + 1, first + 12)) {
|
|
115
|
+
if (/^\s*(Expected|Received|Actual|But was|Call log)\b/i.test(l)) out.push(tidy(l))
|
|
116
|
+
if (out.length === 3) break
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
return [firstError(output)].filter(Boolean)
|
|
121
|
+
}
|
|
122
|
+
|
|
84
123
|
/** The first line of a test run's output that names the error. */
|
|
85
124
|
function firstError(output: string): string {
|
|
86
125
|
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean)
|
|
@@ -116,6 +155,8 @@ export async function runCodeAgent(opts: {
|
|
|
116
155
|
knownDefect?: KnownDefect
|
|
117
156
|
/** The test the case already has, which failed when ForgeQE ran it: the job updates it. */
|
|
118
157
|
existingTest?: ExistingTest
|
|
158
|
+
/** An existing test chosen from measured coverage: reused as it is, or extended with what the run adds. */
|
|
159
|
+
target?: TestTarget
|
|
119
160
|
}): Promise<CodeResult> {
|
|
120
161
|
const given = path.resolve(opts.repo)
|
|
121
162
|
if (!existsSync(given) || !statSync(given).isDirectory()) throw new Error(`Repo not found: ${given}`)
|
|
@@ -334,7 +375,7 @@ export async function runCodeAgent(opts: {
|
|
|
334
375
|
let prompt =
|
|
335
376
|
resumed && earlier?.stage === "writing"
|
|
336
377
|
? carryOnPrompt(liveOn)
|
|
337
|
-
: `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}) })}`
|
|
378
|
+
: `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}), ...(opts.target ? { target: opts.target } : {}) })}`
|
|
338
379
|
if (prepared) {
|
|
339
380
|
stage = "writing"
|
|
340
381
|
saveSession()
|
|
@@ -495,7 +536,7 @@ export async function runCodeAgent(opts: {
|
|
|
495
536
|
const used = now && changes ? outlines(now, classesUsedBy(now, changes)) : ""
|
|
496
537
|
const settings = (repoProfile?.settings || []).map((st) => `- ${st.file}: ${Object.entries(st.values).map(([k, v]) => `${k}=${v}`).join(", ")}`)
|
|
497
538
|
// After a rejection or a tidy-up, the reviewer checks its own earlier issues instead of starting over.
|
|
498
|
-
const verdict = await reviewer.review({ handoff, report, output: run.output, browsers: run.browsers, seconds: secs, changes, before: preexisting(before), ...(facts.length ? { facts } : {}), ...(used ? { outlines: used } : {}), ...(settings.length ? { settings } : {}), ...(result.review ? { earlier: result.review } : {}) })
|
|
539
|
+
const verdict = await reviewer.review({ handoff, report, output: run.output, browsers: run.browsers, seconds: secs, changes, before: preexisting(before), ...(facts.length ? { facts } : {}), ...(used ? { outlines: used } : {}), ...(settings.length ? { settings } : {}), ...(result.review ? { earlier: result.review } : {}), ...(opts.target ? { target: opts.target } : {}) })
|
|
499
540
|
if (opts.signal?.aborted) {
|
|
500
541
|
result.status = "stopped"
|
|
501
542
|
break
|
|
@@ -528,6 +569,9 @@ export async function runCodeAgent(opts: {
|
|
|
528
569
|
continue
|
|
529
570
|
}
|
|
530
571
|
result.status = "passed"
|
|
572
|
+
// What it did with the existing test it was given, for the result card and the agent.
|
|
573
|
+
if (opts.existingTest) result.existing = "updated"
|
|
574
|
+
else if (opts.target) result.existing = opts.target.mode === "reuse" ? "reused" : opts.target.mode === "update" ? "updated" : "extended"
|
|
531
575
|
if (standards.length) {
|
|
532
576
|
result.review = { ...verdict.review, approved: true, standardsLeft: true }
|
|
533
577
|
result.notes = `The test passes and tests the goal. Coding standards still to fix: ${standards.map((i) => `${i.file ? `${i.file}: ` : ""}${i.problem}`).join(" ")}`
|
|
@@ -553,7 +597,24 @@ export async function runCodeAgent(opts: {
|
|
|
553
597
|
finishWithKept(attempt, `the coding-standard fix made the test fail (${run.exitCode === null ? "timed out" : `exit ${run.exitCode}`})`)
|
|
554
598
|
break
|
|
555
599
|
}
|
|
600
|
+
// A test reused as it is fails: changing it changes checks someone relied on, so the user decides first.
|
|
601
|
+
if (opts.target?.mode === "reuse") {
|
|
602
|
+
const reason = failureReason(run.output)[0] || "no error message"
|
|
603
|
+
result.notes = `The existing test ${opts.target.test} (${opts.target.testFile}) covers this flow but fails now: ${reason}. Nothing was changed. Ask the user whether to update that test, or write a new one.`
|
|
604
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file || opts.target.testFile} fails now: ${reason}; not changed, the user decides` })
|
|
605
|
+
break
|
|
606
|
+
}
|
|
556
607
|
const why = run.exitCode === null ? `timed out after ${config.code.testTimeoutSeconds}s` : `exit ${run.exitCode}`
|
|
608
|
+
// ForgeQE's own replay of the run could not get through the flow either: no script reproduces it as recorded,
|
|
609
|
+
// so more attempts only spend tokens. Stop with what the replay met, for the user to act on.
|
|
610
|
+
const unreproducible = replay && !replay.error && replay.passes.length > 0 && replay.passes.every((p) => !p.reached)
|
|
611
|
+
if (unreproducible) {
|
|
612
|
+
const stuck = replay!.passes[0]!.steps.find((st) => !st.ok && !st.skipped)
|
|
613
|
+
const reason = failureReason(run.output)[0] || why
|
|
614
|
+
result.notes = `Stopped after attempt ${attempt}: the test failed (${reason}), and ForgeQE's replay of the run could not reach its final page either${stuck ? ` (step ${stuck.n}, ${stuck.action}: ${String(stuck.error || "").split("\n")[0]!.slice(0, 200)})` : ""}. The flow as recorded cannot be repeated by a script: usually a step the run did in a way a test cannot (a script in the page, state left from an earlier run, a pop-up only the agent dismissed). Run the case again after fixing that step, or check the handoff's replay section.`
|
|
615
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: the flow cannot be reproduced; ForgeQE's replay stopped at ${stuck ? `step ${stuck.n}` : "a step"} as well (attempt ${attempt}/${maxAttempts})` })
|
|
616
|
+
break
|
|
617
|
+
}
|
|
557
618
|
const mismatch = browserMismatch(runBrowser, run.browsers, report.command)
|
|
558
619
|
if (mismatch) emit({ type: "code", kind: "info", text: `The test's browser differs from the run's: ${run.browsers.join(", ")}` })
|
|
559
620
|
const key = failureKey(run.output)
|
|
@@ -565,16 +626,16 @@ export async function runCodeAgent(opts: {
|
|
|
565
626
|
break
|
|
566
627
|
}
|
|
567
628
|
if (sameFailures === 1) {
|
|
568
|
-
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed the same way as the last attempt, ${why} (attempt ${attempt}/${maxAttempts})` })
|
|
629
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed the same way as the last attempt, ${why} (attempt ${attempt}/${maxAttempts}): ${failureReason(run.output)[0] || "no error message"}` })
|
|
569
630
|
attemptLog.push(attemptLine(attempt, `failed, ${why}, the same way as before`, firstError(run.output), report.notes))
|
|
570
631
|
nextTask = repeatedFailurePrompt(sameFailures + 1, report.command, run.exitCode, run.output, files) + mismatch + switchLiveOn()
|
|
571
|
-
next = { why: `the test failed the same way again (${why}); Scribe looks at the failing step first`, fixing:
|
|
632
|
+
next = { why: `the test failed the same way again (${why}); Scribe looks at the failing step first`, fixing: failureReason(run.output) }
|
|
572
633
|
continue
|
|
573
634
|
}
|
|
574
|
-
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed, ${why} (attempt ${attempt}/${maxAttempts})` })
|
|
635
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed, ${why} (attempt ${attempt}/${maxAttempts}): ${failureReason(run.output)[0] || "no error message"}` })
|
|
575
636
|
attemptLog.push(attemptLine(attempt, `failed, ${why}`, firstError(run.output), report.notes))
|
|
576
637
|
nextTask = fixPrompt(attempt, report.command, run.exitCode, run.output, files) + mismatch
|
|
577
|
-
next = { why: `the test failed (${why})`, fixing:
|
|
638
|
+
next = { why: `the test failed (${why})`, fixing: failureReason(run.output) }
|
|
578
639
|
}
|
|
579
640
|
keepSession = result.status === "stopped" || runtimeFailed
|
|
580
641
|
if (report.test_name) result.testName = report.test_name
|
package/src/code/notes.ts
CHANGED
|
@@ -73,3 +73,79 @@ export function notesSection(n: RepoNotes): string {
|
|
|
73
73
|
lines.push("", "Start from these notes instead of reading the repository again: read only the files this test uses or changes, and read a file before you edit or reuse it, since it may have changed.")
|
|
74
74
|
return lines.join("\n")
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Words that say how a goal is phrased, not what flow it is: "verify each value is accepted and remains present" and
|
|
79
|
+
* "verify the values are accepted" ask for the same thing. Left out when two goals are compared.
|
|
80
|
+
*/
|
|
81
|
+
const FILLER = new Set(
|
|
82
|
+
"a an and the of in on to with within only each every any all then this that is are be as it its if for from by or not when also into page step steps result results report run test flow treat fail verify verifying check checks expected appears appear present remains accepted"
|
|
83
|
+
.split(" "),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
/** A goal's content words, endings off ("toggles", "toggled" are one), for telling the same flow from another. */
|
|
87
|
+
function goalWords(goal: string): Set<string> {
|
|
88
|
+
return new Set(
|
|
89
|
+
goal
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/https?:\/\/(www\.)?/g, "")
|
|
92
|
+
.split(/[^a-z0-9.@-]+/)
|
|
93
|
+
.map((w) => w.replace(/^[.-]+|[.-]+$/g, ""))
|
|
94
|
+
.filter((w) => w.length > 1 && !FILLER.has(w))
|
|
95
|
+
.map((w) => w.replace(/(ing|ed|es|s)$/, "")),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The website a goal names, if any: the same steps on another site are another flow. */
|
|
100
|
+
function goalHost(goal: string): string {
|
|
101
|
+
return /https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(goal)?.[1]?.toLowerCase() || ""
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* How alike two goals are, 0 to 1: the content words they share, over the words they have (Dice). Measured on real
|
|
106
|
+
* goals ForgeQE's agent wrote: the same flow reworded scores 0.88; a shorter flow on the same page 0.59, and the same
|
|
107
|
+
* flow with a different final check 0.67.
|
|
108
|
+
*/
|
|
109
|
+
export function goalSimilarity(a: string, b: string): number {
|
|
110
|
+
const [ha, hb] = [goalHost(a), goalHost(b)]
|
|
111
|
+
if (ha && hb && ha !== hb) return 0
|
|
112
|
+
const x = goalWords(a)
|
|
113
|
+
const y = goalWords(b)
|
|
114
|
+
if (!x.size || !y.size) return 0
|
|
115
|
+
let shared = 0
|
|
116
|
+
for (const w of x) if (y.has(w)) shared++
|
|
117
|
+
return (2 * shared) / (x.size + y.size)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Goals this alike are the same flow asked again; ForgeQE's agent rewords a goal from one request to the next. */
|
|
121
|
+
export const SAME_GOAL = 0.8
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether `goal` asks again for the flow an earlier test was written for. Rewording adds words and keeps the earlier
|
|
125
|
+
* ones; another flow drops what the earlier goal checked ("pie chart visible" becoming "list can be reordered"), so at
|
|
126
|
+
* most one of the earlier goal's content words may be missing, and the two must be alike overall.
|
|
127
|
+
*/
|
|
128
|
+
export function sameFlow(earlier: string, goal: string): boolean {
|
|
129
|
+
if (goalSimilarity(earlier, goal) < SAME_GOAL) return false
|
|
130
|
+
const now = goalWords(goal)
|
|
131
|
+
let dropped = 0
|
|
132
|
+
for (const w of goalWords(earlier)) if (!now.has(w)) dropped++
|
|
133
|
+
return dropped <= 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The test an earlier job wrote in this repository for the same goal, when its file is still there: the newest of the
|
|
138
|
+
* closest matches. Notes of any age count here, since the test file itself is what is checked.
|
|
139
|
+
*/
|
|
140
|
+
export function findWrittenTest(memoryDir: string, repo: string, goal: string, exists: (file: string) => boolean): WrittenTest | null {
|
|
141
|
+
const tests = load(memoryDir).repos[repo]?.tests || []
|
|
142
|
+
let best: { test: WrittenTest; score: number } | null = null
|
|
143
|
+
for (const t of tests) {
|
|
144
|
+
const score = goalSimilarity(t.goal, goal)
|
|
145
|
+
if (sameFlow(t.goal, goal) && exists(t.test_file) && (!best || score > best.score || (score === best.score && t.at > best.test.at))) best = { test: t, score }
|
|
146
|
+
}
|
|
147
|
+
return best?.test || null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Where browser_run leaves the earlier test for a goal that failed, so write_test updates it instead of adding one. */
|
|
151
|
+
export const EXISTING_TEST_FILE = "existing-test.json"
|
package/src/code/prompt.ts
CHANGED
|
@@ -74,6 +74,49 @@ export function preparePrompt(goal: string, checking = true): string {
|
|
|
74
74
|
/** A test this case already has, which now fails: the job updates it instead of writing a new one. */
|
|
75
75
|
export type ExistingTest = { testFile: string; command: string; output: string; why: string }
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* An existing test the job works from, chosen from measured coverage (or by the person): `reuse` it as it is, or
|
|
79
|
+
* `extend` it with only the steps and checks it lacks.
|
|
80
|
+
*/
|
|
81
|
+
export type TestTarget = { mode: "reuse" | "extend" | "update"; testFile: string; test: string; line: number; covered: string[]; missing: string[]; why: string }
|
|
82
|
+
|
|
83
|
+
function targetSection(t: TestTarget): string[] {
|
|
84
|
+
const list = (items: string[]) => items.map((i) => `- ${i}`)
|
|
85
|
+
if (t.mode === "update") {
|
|
86
|
+
return [
|
|
87
|
+
"## Update the existing test, do not write a new one",
|
|
88
|
+
"",
|
|
89
|
+
`The user asked to update ${t.test} (${t.testFile}:${t.line}) so it matches the verified run below. Change only what no longer matches the run (a step, a locator, an expected value); keep its other steps and assertions. Do not create a new test file.`,
|
|
90
|
+
"Do not change what an existing page-object member or method does for the other tests that use it (code_references shows them); add a member instead, or keep its behaviour for them, and say so in notes.",
|
|
91
|
+
"",
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
if (t.mode === "reuse") {
|
|
95
|
+
return [
|
|
96
|
+
"## The existing test that already covers this flow",
|
|
97
|
+
"",
|
|
98
|
+
`ForgeQE's code graph found that ${t.test} (${t.testFile}:${t.line}) already reaches every element of this run (${t.why}). Do not write or change any test or page object.`,
|
|
99
|
+
"Find the command that runs only that test (from your notes on this repository, or the repository's test setup), and give the structured result with that test_file, test_name, and command. ForgeQE runs it. If it fails, ForgeQE stops and asks the user before anything is changed.",
|
|
100
|
+
"",
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
"## Extend the existing test, do not write a new one",
|
|
105
|
+
"",
|
|
106
|
+
`ForgeQE's code graph found that ${t.test} (${t.testFile}:${t.line}) already covers most of this flow (${t.why}). Extend that test: add only what it lacks, in the order of the flow, through the same page objects and helpers. Keep every step and assertion it has. Do not create a new test file.`,
|
|
107
|
+
"",
|
|
108
|
+
"It already covers:",
|
|
109
|
+
...list(t.covered),
|
|
110
|
+
"",
|
|
111
|
+
"Add only these:",
|
|
112
|
+
...list(t.missing),
|
|
113
|
+
"",
|
|
114
|
+
"When the missing steps start from a different place than the test does, add one test method next to it in the same class, reusing its setup and helpers, instead of a new file.",
|
|
115
|
+
"Add page-object members for elements the repository does not locate yet. Do not change what an existing member or method does: other tests use it (code_references shows which). When one must change, keep its behaviour for those tests and say so in notes.",
|
|
116
|
+
"",
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
|
|
77
120
|
function existingSection(e: ExistingTest): string[] {
|
|
78
121
|
return [
|
|
79
122
|
"## The test this case already has",
|
|
@@ -91,7 +134,7 @@ function existingSection(e: ExistingTest): string[] {
|
|
|
91
134
|
]
|
|
92
135
|
}
|
|
93
136
|
|
|
94
|
-
export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest } = {}): string {
|
|
137
|
+
export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest; target?: TestTarget } = {}): string {
|
|
95
138
|
const live = opts.liveBrowserDir
|
|
96
139
|
? opts.liveOn
|
|
97
140
|
? `Your live_browser_* tools are on because the replay failed at a step; use them on that step's page. They save snapshots and screenshots in ${opts.liveBrowserDir}.`
|
|
@@ -100,9 +143,15 @@ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; l
|
|
|
100
143
|
return [
|
|
101
144
|
opts.existing
|
|
102
145
|
? `Update the existing test for the flow below so it passes again. When it is updated, give the structured result; ForgeQE then runs it.`
|
|
103
|
-
:
|
|
146
|
+
: opts.target?.mode === "reuse"
|
|
147
|
+
? `An existing test already covers the flow below. Report how to run it; change nothing. ForgeQE then runs it.`
|
|
148
|
+
: opts.target?.mode === "extend"
|
|
149
|
+
? `Extend the existing test named below with what the flow below adds. When it is extended, give the structured result; ForgeQE then runs it.`
|
|
150
|
+
: opts.target?.mode === "update"
|
|
151
|
+
? `Update the existing test named below to match the flow below. When it is updated, give the structured result; ForgeQE then runs it.`
|
|
152
|
+
: `Write a test in this repository for the flow below, reusing the repository's existing test code. When it is written, give the structured result; ForgeQE then runs it.`,
|
|
104
153
|
"",
|
|
105
|
-
...(opts.existing ? existingSection(opts.existing) : []),
|
|
154
|
+
...(opts.existing ? existingSection(opts.existing) : opts.target ? targetSection(opts.target) : []),
|
|
106
155
|
...(opts.profile ? [opts.profile, ""] : []),
|
|
107
156
|
...(opts.notes ? [opts.notes, ""] : []),
|
|
108
157
|
handoff.markdown,
|
package/src/code/replay.ts
CHANGED
|
@@ -135,11 +135,31 @@ function describe(step: Step): string {
|
|
|
135
135
|
return "go back"
|
|
136
136
|
case "evaluate":
|
|
137
137
|
return "run the agent's JavaScript"
|
|
138
|
+
case "api":
|
|
139
|
+
return `API ${step.method || "GET"} ${step.url}`
|
|
138
140
|
default:
|
|
139
141
|
return `${step.op || "unknown"}${step.action_keys?.length ? ` (${step.action_keys.join(", ")})` : ""}`
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* An API step as the engine's api_request makes it (py/forgeqe_engine/api_step.py): fetch in the page with its cookies,
|
|
147
|
+
* the CSRF token from the cookie or the page's form, sent in headers and, for a form post, in the form.
|
|
148
|
+
*/
|
|
149
|
+
export const API_CALL_JS = `async (a) => {
|
|
150
|
+
const cookie = (n) => { const c = document.cookie.split('; ').find((x) => x.startsWith(n + '=')); return c ? decodeURIComponent(c.slice(n.length + 1)) : '' };
|
|
151
|
+
let token = '';
|
|
152
|
+
if (a.csrf === 'cookie') token = cookie('csrftoken') || cookie('XSRF-TOKEN') || cookie('csrf_token') || cookie('_csrf');
|
|
153
|
+
if (a.csrf === 'form') { const f = document.querySelector('input[name="csrfmiddlewaretoken"], input[name="_csrf"], input[name="csrf_token"], input[name="authenticity_token"], input[name="__RequestVerificationToken"]'); const m = document.querySelector('meta[name="csrf-token"], meta[name="_csrf"]'); token = (f && f.value) || (m && m.content) || ''; }
|
|
154
|
+
const headers = {};
|
|
155
|
+
if (a.contentType) headers['Content-Type'] = a.contentType;
|
|
156
|
+
let body = a.body || undefined;
|
|
157
|
+
if (token) { headers['X-CSRFToken'] = token; headers['X-XSRF-TOKEN'] = token; headers['X-CSRF-Token'] = token; if (/x-www-form-urlencoded/i.test(a.contentType || '') && !/(^|&)(csrfmiddlewaretoken|_csrf|authenticity_token)=/.test(body || '')) body = (body ? body + '&' : '') + 'csrfmiddlewaretoken=' + encodeURIComponent(token); }
|
|
158
|
+
const method = (a.method || 'GET').toUpperCase();
|
|
159
|
+
try { const r = await fetch(a.url, { method, headers, body: method === 'GET' || method === 'HEAD' ? undefined : body, credentials: 'include', redirect: 'follow' }); return { status: r.status, final_url: r.url }; }
|
|
160
|
+
catch (e) { return { error: String(e && e.message || e) }; }
|
|
161
|
+
}`
|
|
162
|
+
|
|
143
163
|
/** The body of one step in the script, or null when it has no Playwright equivalent. */
|
|
144
164
|
function stepCode(step: Step): string | null {
|
|
145
165
|
const loc = step.locators?.map(locatorExpr).find(Boolean) || null
|
|
@@ -171,6 +191,19 @@ function stepCode(step: Step): string | null {
|
|
|
171
191
|
return "(page) => page.goBack()"
|
|
172
192
|
case "evaluate":
|
|
173
193
|
return step.code ? `(page) => page.evaluate(${q(step.code)})` : null
|
|
194
|
+
case "api": {
|
|
195
|
+
if (!step.url) return null
|
|
196
|
+
// The call is made in the page, as the run made it: the session's cookies and its CSRF token go with it. The step
|
|
197
|
+
// fails when the server answers differently than it did in the run (403 where the run got 200).
|
|
198
|
+
const args = JSON.stringify({ method: step.method || "GET", url: step.url, body: step.body || "", contentType: step.content_type || "", csrf: step.csrf || "" })
|
|
199
|
+
return [
|
|
200
|
+
"async (page) => {",
|
|
201
|
+
` const r = await page.evaluate(${API_CALL_JS}, ${args})`,
|
|
202
|
+
" if (r.error) throw new Error(`API ${r.error}`)",
|
|
203
|
+
...(step.status ? [` if (r.status !== ${Number(step.status)}) throw new Error(\`API answered \${r.status}, the run got ${Number(step.status)}\`)`] : []),
|
|
204
|
+
" }",
|
|
205
|
+
].join("\n")
|
|
206
|
+
}
|
|
174
207
|
default:
|
|
175
208
|
return null
|
|
176
209
|
}
|