@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.
@@ -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
- 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)." })
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
+ }
@@ -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
  }
@@ -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) {
@@ -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: [firstError(run.output)].filter(Boolean) }
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: [firstError(run.output)].filter(Boolean) }
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"
@@ -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
- : `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.`,
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,
@@ -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
  }