@capagents/forgeqe 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/HOW_TO_USE.md +54 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +66 -8
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +36 -4
  9. package/prompts/browser-api.md +13 -0
  10. package/prompts/browser-capture.md +2 -2
  11. package/prompts/browser-checks.md +2 -0
  12. package/py/forgeqe_engine/api_step.py +130 -0
  13. package/py/forgeqe_engine/capture.py +53 -2
  14. package/py/forgeqe_engine/checks.py +8 -2
  15. package/py/forgeqe_engine/llm.py +33 -5
  16. package/py/forgeqe_engine/memory.py +70 -2
  17. package/py/forgeqe_engine/observe.py +123 -0
  18. package/py/forgeqe_engine/replay.py +55 -2
  19. package/py/forgeqe_engine/service.py +177 -5
  20. package/src/api/fromSteps.ts +111 -0
  21. package/src/capabilities/registry.ts +19 -5
  22. package/src/cases/ado.ts +1 -1
  23. package/src/cases/journey.ts +61 -0
  24. package/src/cases/quality.ts +3 -1
  25. package/src/cases/readiness.ts +35 -6
  26. package/src/cases/save.ts +241 -48
  27. package/src/cases/types.ts +11 -0
  28. package/src/cli/help.ts +8 -0
  29. package/src/cli/index.ts +64 -1
  30. package/src/code/coverage.ts +22 -3
  31. package/src/code/decisions.ts +73 -0
  32. package/src/code/find.ts +3 -1
  33. package/src/code/graph/graph.ts +33 -2
  34. package/src/code/handoff.ts +20 -0
  35. package/src/code/loop.ts +27 -1
  36. package/src/code/notes.ts +48 -1
  37. package/src/code/prompt.ts +31 -9
  38. package/src/code/protect.ts +103 -0
  39. package/src/code/replay.ts +33 -0
  40. package/src/code/review.ts +2 -0
  41. package/src/code/runtime.ts +35 -3
  42. package/src/code/shortlist.ts +309 -0
  43. package/src/code/testrun.ts +3 -1
  44. package/src/code/traceability.ts +2 -0
  45. package/src/commands.ts +6 -1
  46. package/src/config.ts +46 -0
  47. package/src/engine/bridge.ts +47 -5
  48. package/src/mcp/casetools.ts +107 -11
  49. package/src/mcp/commands.ts +74 -20
  50. package/src/mcp/packtools.ts +187 -0
  51. package/src/mcp/tools.ts +238 -29
  52. package/src/pack/collect.ts +251 -0
  53. package/src/pack/index.ts +134 -0
  54. package/src/pack/init.ts +45 -0
  55. package/src/pack/pack.ts +327 -0
  56. package/src/pack/pipeline.ts +373 -0
  57. package/src/pack/run.ts +155 -0
  58. package/src/registry/db.ts +249 -0
  59. package/src/registry/index.ts +72 -0
  60. package/src/registry/resolve.ts +57 -0
  61. package/src/registry/tier0.ts +81 -0
  62. package/src/report/data.ts +7 -0
  63. package/src/report/html.ts +14 -0
  64. package/src/report/template.html +52 -48
  65. package/src/report/usage.ts +113 -0
  66. package/src/run.ts +32 -2
  67. package/src/suite/report.ts +22 -4
  68. package/src/suite/run.ts +13 -5
  69. package/src/summary.ts +6 -0
  70. package/src/tokenomics/budget.ts +172 -0
  71. package/src/tokenomics/ledger.ts +287 -0
  72. package/src/tui/App.tsx +90 -2
  73. package/src/tui/inspector.tsx +40 -35
  74. package/src/tui/transcript.tsx +5 -4
  75. package/src/types.ts +11 -2
@@ -19,9 +19,10 @@ import type { Check, Locator, Step } from "./handoff.ts"
19
19
  export type CoverageItem = { label: string; kind: "step" | "check"; members: string[] }
20
20
 
21
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 }
22
+ export type Candidate = { test: string; file: string; line: number; covered: number[]; missing: number[]; share: number; lines?: number }
23
23
 
24
- export type Mode = "new" | "extend" | "reuse" | "update"
24
+ /** sibling: a new test method beside an existing one, in its class, reusing its setup and steps. */
25
+ export type Mode = "new" | "extend" | "reuse" | "update" | "sibling"
25
26
 
26
27
  /** `ask`: the evidence fits more than one way, so the person chooses (the options, each with what it changes). */
27
28
  export type Decision = { mode: Mode; ask: boolean; target?: Candidate; why: string }
@@ -32,6 +33,10 @@ export type Coverage = { items: CoverageItem[]; candidates: Candidate[]; decisio
32
33
  export const EXTEND_FROM = 0.4
33
34
  /** Another test this close to the best one makes the choice the person's. */
34
35
  const RIVAL = 0.75
36
+ /** A test this long is not made longer: the new steps go in a test beside it, which reuses its setup and steps. */
37
+ export const MAX_EXTEND_LINES = 80
38
+ /** With no one to ask, a test covering at least this share is extended; one covering less gets a test beside it. */
39
+ export const EXTEND_ALONE_FROM = 0.6
35
40
 
36
41
  type RunJson = { final_url?: string; memory?: { dir?: string } }
37
42
 
@@ -84,7 +89,7 @@ export function measureCoverage(runDir: string, graph: CodeGraph, memoryDir?: st
84
89
  // test in its own way, so it is unknown, not missing.
85
90
  const measurable = items.flatMap((it, i) => (it.members.length ? [i] : []))
86
91
  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 }))
92
+ .map(({ at, covered }) => ({ test: qualified(at), file: at.file, line: at.member.line, lines: at.member.endLine - at.member.line + 1, covered: [...covered].sort((a, b) => a - b), missing: measurable.filter((i) => !covered.has(i)), share: measurable.length ? covered.size / measurable.length : 0 }))
88
93
  .sort((a, b) => b.covered.length - a.covered.length || a.missing.length - b.missing.length)
89
94
  return { items, candidates, decision: decide(items, candidates) }
90
95
  }
@@ -104,6 +109,20 @@ export function decide(items: CoverageItem[], candidates: Candidate[]): Decision
104
109
  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
110
  }
106
111
 
112
+ /**
113
+ * The decision when it is ForgeQE's to make, not the person's: nobody can be asked (a pipeline, a plain run) or the
114
+ * choice came from ForgeQE's agent. Tests stay short: a long test gets a test beside it instead of more steps. Unclear
115
+ * between close tests, the best is extended when it covers most of the flow, else a test is added beside it.
116
+ */
117
+ export function settle(d: Decision): Decision {
118
+ const t = d.target
119
+ if (!t || d.mode === "new" || d.mode === "update") return { ...d, ask: false }
120
+ if (d.mode === "reuse") return { ...d, ask: false, why: d.ask ? `${d.why}; with no one to ask, the one covering the most is reused` : d.why }
121
+ if (t.lines && t.lines > MAX_EXTEND_LINES) return { ...d, mode: "sibling", ask: false, why: `${d.why}; ${t.test} is already ${t.lines} lines, so the new steps go in a test beside it` }
122
+ if (d.ask && t.share < EXTEND_ALONE_FROM) return { ...d, mode: "sibling", ask: false, why: `${d.why}; with no one to ask and ${t.test} covering ${Math.round(t.share * 100)}%, a test beside it rather than a longer one` }
123
+ return { ...d, ask: false, why: d.ask ? `${d.why}; with no one to ask, the one covering the most (${Math.round(t.share * 100)}%) is extended` : d.why }
124
+ }
125
+
107
126
  /** The evidence as card lines: each close test, what it covers, and what it lacks. */
108
127
  export function coverageLines(c: Coverage, max = 3): string[] {
109
128
  const n = c.items.filter((i) => i.members.length).length
@@ -0,0 +1,73 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { logEvent } from "../sessionLog.ts"
5
+ import { projectDir } from "../tools.ts"
6
+
7
+ /**
8
+ * Every decision about existing tests, kept so it can be read back: which tests were shown before a browser run, who
9
+ * chose what (ForgeQE's agent, the user, ForgeQE's policy, the coverage measure, or a test an earlier job wrote for the
10
+ * same flow), why, and how it ended. One JSON line each in .forgeqe/decisions.jsonl in the repository (commit it, or
11
+ * keep it between CI runs, like test-history.json), and in the session log. What a later job learns from comes from here.
12
+ */
13
+
14
+ export const DECISIONS_FILE = "decisions.jsonl"
15
+ /** Past this size the oldest half is dropped. */
16
+ const MAX_BYTES = 2_000_000
17
+
18
+ export type Shown = { test?: string; file: string; score?: number; covers?: string[]; lacks?: string[] }
19
+
20
+ export type DecisionRecord = {
21
+ at: string
22
+ goal: string
23
+ /** shown: tests offered before the browser; chosen: what was picked; outcome: how it ended. */
24
+ stage: "shown" | "chosen" | "outcome"
25
+ /** Where it was decided: before the browser run, or in write_test after it. */
26
+ where: "browser_run" | "write_test"
27
+ by?: "agent" | "user" | "policy" | "coverage" | "history"
28
+ mode?: "reuse" | "extend" | "sibling" | "update" | "new" | "fresh"
29
+ test?: string
30
+ file?: string
31
+ why?: string
32
+ shown?: Shown[]
33
+ status?: string
34
+ existing?: string
35
+ run_dir?: string
36
+ /** Times Scribe changed the existing test it was asked to keep, and was sent back. */
37
+ breaches?: number
38
+ }
39
+
40
+ export const decisionsFile = (repo: string) => path.join(projectDir(repo), DECISIONS_FILE)
41
+
42
+ export function recordDecision(repo: string, rec: Omit<DecisionRecord, "at">): void {
43
+ const full: DecisionRecord = { at: new Date().toISOString(), ...rec }
44
+ logEvent("decision", full)
45
+ const file = decisionsFile(repo)
46
+ try {
47
+ mkdirSync(path.dirname(file), { recursive: true })
48
+ appendFileSync(file, `${JSON.stringify(full)}\n`)
49
+ if (statSync(file).size > MAX_BYTES) {
50
+ const lines = readFileSync(file, "utf8").trim().split("\n")
51
+ writeFileSync(file, `${lines.slice(Math.floor(lines.length / 2)).join("\n")}\n`)
52
+ }
53
+ } catch {
54
+ // The record is for later jobs; a repository ForgeQE cannot write to still gets its test.
55
+ }
56
+ }
57
+
58
+ export function readDecisions(repo: string): DecisionRecord[] {
59
+ try {
60
+ return readFileSync(decisionsFile(repo), "utf8")
61
+ .split("\n")
62
+ .filter(Boolean)
63
+ .flatMap((l) => {
64
+ try {
65
+ return [JSON.parse(l) as DecisionRecord]
66
+ } catch {
67
+ return []
68
+ }
69
+ })
70
+ } catch {
71
+ return []
72
+ }
73
+ }
package/src/code/find.ts CHANGED
@@ -382,6 +382,8 @@ export async function runSelection(opts: {
382
382
  hide?: string[]
383
383
  /** Retries and quarantine; default: one retry, quarantine suggested. */
384
384
  flaky?: FlakyConfig
385
+ /** Extra variables for the tests, such as a remote browser's endpoint. */
386
+ env?: Record<string, string>
385
387
  }): Promise<TestsRun> {
386
388
  const sel = opts.selection
387
389
  const flakyCfg = opts.flaky || { retries: 1, quarantine: "suggest", flakyAfter: 2 }
@@ -389,7 +391,7 @@ export async function runSelection(opts: {
389
391
  mkdirSync(sel.dir, { recursive: true })
390
392
  for (const p of sel.results) if (p.startsWith(sel.dir) && existsSync(p) && statSync(p).isFile()) rmSync(p)
391
393
  const started = Date.now()
392
- const exec = (command: string) => (opts.runTest || runTestCommand)({ command, cwd: sel.repo, timeoutSeconds: opts.timeoutSeconds, signal: opts.signal, hide: opts.hide })
394
+ const exec = (command: string) => (opts.runTest || runTestCommand)({ command, cwd: sel.repo, timeoutSeconds: opts.timeoutSeconds, signal: opts.signal, hide: opts.hide, ...(opts.env ? { env: opts.env } : {}) })
393
395
  const read = (since: number) => {
394
396
  const out: TestOutcome[] = []
395
397
  for (const f of resultFiles(sel.results, since)) {
@@ -86,8 +86,15 @@ export async function buildGraph(repo: string, opts: { cacheDir?: string } = {})
86
86
  /** A member (or top-level function or test) with where it is. */
87
87
  export type Located = { file: string; cls?: ClassFacts; member: Member }
88
88
 
89
+ const membersOf = new WeakMap<CodeGraph, Located[]>()
90
+
91
+ /** Every member of the graph; worked out once per graph, which does not change once built. */
89
92
  export function allMembers(g: CodeGraph): Located[] {
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 }))])
93
+ const known = membersOf.get(g)
94
+ if (known) return known
95
+ const all = 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 }))])
96
+ membersOf.set(g, all)
97
+ return all
91
98
  }
92
99
 
93
100
  export const qualified = (l: Located) => (l.cls ? `${l.cls.name}.${l.member.name}` : l.member.name)
@@ -314,8 +321,21 @@ export function describeMatches(g: CodeGraph, matches: ElementMatch[], max = 3):
314
321
  // References
315
322
 
316
323
  /** The class a receiver names: from the file's declarations, then any file's, then by name (articlePage → WikipediaArticlePage). */
324
+ const receivers = new WeakMap<CodeGraph, Map<string, string | null>>()
325
+
317
326
  function receiverClass(g: CodeGraph, file: FileFacts, receiver: string, classes: Set<string>): string | null {
318
327
  if (!receiver) return null
328
+ // The graph does not change once built: each file's receiver resolves the same way every time it is asked.
329
+ let cache = receivers.get(g)
330
+ if (!cache) receivers.set(g, (cache = new Map()))
331
+ const key = `${file.file}\u0000${receiver}\u0000${classes.size}`
332
+ if (cache.has(key)) return cache.get(key)!
333
+ const found = resolveReceiver(g, file, receiver, classes)
334
+ cache.set(key, found)
335
+ return found
336
+ }
337
+
338
+ function resolveReceiver(g: CodeGraph, file: FileFacts, receiver: string, classes: Set<string>): string | null {
319
339
  const last = receiver.split(".").pop() || receiver
320
340
  if (classes.has(last)) return last
321
341
  const bound = file.bindings[receiver] || file.bindings[last]
@@ -331,13 +351,24 @@ function receiverClass(g: CodeGraph, file: FileFacts, receiver: string, classes:
331
351
  }
332
352
 
333
353
  /** The class and its base classes, nearest first. */
354
+ const lineages = new WeakMap<CodeGraph, { byName: Map<string, ClassFacts>; memo: Map<string, string[]> }>()
355
+
334
356
  function lineage(g: CodeGraph, name: string): string[] {
357
+ let c = lineages.get(g)
358
+ if (!c) {
359
+ const byName = new Map<string, ClassFacts>()
360
+ for (const f of g.files) for (const cls of f.classes) if (!byName.has(cls.name)) byName.set(cls.name, cls)
361
+ lineages.set(g, (c = { byName, memo: new Map() }))
362
+ }
363
+ const known = c.memo.get(name)
364
+ if (known) return known
335
365
  const out: string[] = []
336
366
  let cur: string | undefined = name
337
367
  while (cur && !out.includes(cur)) {
338
368
  out.push(cur)
339
- cur = g.files.flatMap((f) => f.classes).find((c) => c.name === cur)?.bases[0]
369
+ cur = c.byName.get(cur)?.bases[0]
340
370
  }
371
+ c.memo.set(name, out)
341
372
  return out
342
373
  }
343
374
 
@@ -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
@@ -10,6 +10,7 @@ import type { TestCase } from "../cases/types.ts"
10
10
  import { buildProfile, profileSection } from "./profile.ts"
11
11
  import { LIVE_BROWSER_SERVER, liveBrowser } from "./livebrowser.ts"
12
12
  import { additionFacts, buildGraph, classesUsedBy, outlines, type CodeGraph } from "./graph/graph.ts"
13
+ import { breachPrompt, checkOriginal, readOriginal } from "./protect.ts"
13
14
  import { CODE_GRAPH_SERVER, codeGraphTools } from "./graph/tools.ts"
14
15
  import { localToolServer } from "../mcp/server.ts"
15
16
  import type { ToolContext } from "../mcp/tools.ts"
@@ -19,6 +20,7 @@ import { openSession, READ_AGENT, TEST_AGENT, type OpencodeSession, type PromptO
19
20
  import { failureContext } from "./slices.ts"
20
21
  import { moveToAttention } from "./attention.ts"
21
22
  import { recordMapping } from "./traceability.ts"
23
+ import { stepsHash } from "../cases/types.ts"
22
24
  import {
23
25
  blockedChallengePrompt,
24
26
  carryOnPrompt,
@@ -167,6 +169,8 @@ export async function runCodeAgent(opts: {
167
169
  // locates it, Scribe gets code_* tools instead of reading whole files, and the reviewer gets what the job duplicated.
168
170
  const built = config.code.graph === false ? null : await buildGraph(repo).catch(() => null)
169
171
  const graph: CodeGraph | null = built?.files.length ? built : null
172
+ // The existing test the job works from, as it is now: what Scribe may do to it is checked before each test run.
173
+ const original = opts.target ? await readOriginal(repo, opts.target) : null
170
174
  let handoff = buildHandoff(opts.runDir, config.memory.dir, { knownDefect: opts.knownDefect, graph })
171
175
  if (!handoff.success && !opts.knownDefect) throw new Error("The coding agent only takes runs that passed. This run failed.")
172
176
  const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`
@@ -496,6 +500,17 @@ export async function runCodeAgent(opts: {
496
500
  result.reused = report.reused || []
497
501
  result.changed = report.changed || []
498
502
 
503
+ if (original && opts.target) {
504
+ const breach = await checkOriginal(repo, original, opts.target.mode)
505
+ if (breach) {
506
+ result.breaches = (result.breaches || 0) + 1
507
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${breach.why}: not run; Scribe puts it back (attempt ${attempt}/${allowed})` })
508
+ attemptLog.push(attemptLine(attempt, "changed the existing test it was asked to keep", breach.lines.slice(0, 3).join(" | "), report.notes))
509
+ nextTask = breachPrompt(original, breach, opts.target.mode)
510
+ next = { why: breach.why, fixing: [opts.target.mode === "extend" ? "Put back the existing test's lines exactly; add only the new steps" : "Leave the existing test as it was"] }
511
+ continue
512
+ }
513
+ }
499
514
  emit({ type: "code", kind: "info", text: `ForgeQE runs the test: ${report.command}` })
500
515
  const run = await runTest({
501
516
  command: report.command,
@@ -571,7 +586,7 @@ export async function runCodeAgent(opts: {
571
586
  result.status = "passed"
572
587
  // What it did with the existing test it was given, for the result card and the agent.
573
588
  if (opts.existingTest) result.existing = "updated"
574
- else if (opts.target) result.existing = opts.target.mode === "reuse" ? "reused" : opts.target.mode === "update" ? "updated" : "extended"
589
+ else if (opts.target) result.existing = opts.target.mode === "reuse" ? "reused" : opts.target.mode === "update" ? "updated" : opts.target.mode === "sibling" ? "added_beside" : "extended"
575
590
  if (standards.length) {
576
591
  result.review = { ...verdict.review, approved: true, standardsLeft: true }
577
592
  result.notes = `The test passes and tests the goal. Coding standards still to fix: ${standards.map((i) => `${i.file ? `${i.file}: ` : ""}${i.problem}`).join(" ")}`
@@ -605,6 +620,16 @@ export async function runCodeAgent(opts: {
605
620
  break
606
621
  }
607
622
  const why = run.exitCode === null ? `timed out after ${config.code.testTimeoutSeconds}s` : `exit ${run.exitCode}`
623
+ // ForgeQE's own replay of the run could not get through the flow either: no script reproduces it as recorded,
624
+ // so more attempts only spend tokens. Stop with what the replay met, for the user to act on.
625
+ const unreproducible = replay && !replay.error && replay.passes.length > 0 && replay.passes.every((p) => !p.reached)
626
+ if (unreproducible) {
627
+ const stuck = replay!.passes[0]!.steps.find((st) => !st.ok && !st.skipped)
628
+ const reason = failureReason(run.output)[0] || why
629
+ 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.`
630
+ 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})` })
631
+ break
632
+ }
608
633
  const mismatch = browserMismatch(runBrowser, run.browsers, report.command)
609
634
  if (mismatch) emit({ type: "code", kind: "info", text: `The test's browser differs from the run's: ${run.browsers.join(", ")}` })
610
635
  const key = failureKey(run.output)
@@ -642,6 +667,7 @@ export async function runCodeAgent(opts: {
642
667
  ...(result.testName ? { testName: result.testName } : {}),
643
668
  ...(result.command ? { command: result.command } : {}),
644
669
  ...(opts.knownDefect?.bug ? { bug: opts.knownDefect.bug } : {}),
670
+ ...(tc.steps?.length ? { stepsHash: stepsHash(tc.steps) } : {}),
645
671
  })
646
672
  }
647
673
  // An unfinished job (not one that can be resumed) leaves no failing test among the tests: it goes to needs-attention.
package/src/code/notes.ts CHANGED
@@ -98,7 +98,8 @@ function goalWords(goal: string): Set<string> {
98
98
 
99
99
  /** The website a goal names, if any: the same steps on another site are another flow. */
100
100
  function goalHost(goal: string): string {
101
- return /https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(goal)?.[1]?.toLowerCase() || ""
101
+ // A URL ending a sentence ("Open https://shop.test.") keeps its host without the full stop.
102
+ return (/https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(goal)?.[1]?.toLowerCase() || "").replace(/[.:;!?]+$/, "")
102
103
  }
103
104
 
104
105
  /**
@@ -147,5 +148,51 @@ export function findWrittenTest(memoryDir: string, repo: string, goal: string, e
147
148
  return best?.test || null
148
149
  }
149
150
 
151
+ /**
152
+ * Goals at least this alike may be the same journey, or one a request extends. ForgeQE's agent rewrites the user's
153
+ * request each time, so the same request scores as low as 0.34 against an earlier wording, while unrelated flows
154
+ * measured on real goals stay under 0.21. Too loose to decide on its own: ForgeQE's agent compares the goals.
155
+ */
156
+ export const RELATED_GOAL = 0.25
157
+ /** And they share at least this many content words: two short goals on one site share a word or two by chance. */
158
+ const RELATED_WORDS = 3
159
+
160
+ function sharedWords(a: string, b: string): number {
161
+ const y = goalWords(b)
162
+ let n = 0
163
+ for (const w of goalWords(a)) if (y.has(w)) n++
164
+ return n
165
+ }
166
+
167
+ /**
168
+ * Earlier tests in this repository whose goals are close to `goal` without being the same flow: candidates to reuse or
169
+ * extend before the browser explores anything. One per test file, the closest first.
170
+ */
171
+ export function findRelatedTests(memoryDir: string, repo: string, goal: string, exists: (file: string) => boolean, max = 3): (WrittenTest & { similarity: number })[] {
172
+ const best = new Map<string, WrittenTest & { similarity: number }>()
173
+ for (const t of load(memoryDir).repos[repo]?.tests || []) {
174
+ const similarity = goalSimilarity(t.goal, goal)
175
+ if (similarity < RELATED_GOAL || sharedWords(t.goal, goal) < RELATED_WORDS || !exists(t.test_file)) continue
176
+ const had = best.get(t.test_file)
177
+ if (!had || similarity > had.similarity || (similarity === had.similarity && t.at > had.at)) best.set(t.test_file, { ...t, similarity })
178
+ }
179
+ return [...best.values()].sort((a, b) => b.similarity - a.similarity || b.at.localeCompare(a.at)).slice(0, max)
180
+ }
181
+
182
+ /** Every test earlier jobs wrote in this repository, of any age: their commands show how it runs one test. */
183
+ export function writtenTests(memoryDir: string, repo: string): WrittenTest[] {
184
+ return load(memoryDir).repos[repo]?.tests || []
185
+ }
186
+
187
+ /** The earlier test written in this repository to this file (the newest), for a test ForgeQE's agent names. */
188
+ export function writtenTestFor(memoryDir: string, repo: string, testFile: string): WrittenTest | null {
189
+ const file = testFile.replace(/^\.\//, "")
190
+ const tests = (load(memoryDir).repos[repo]?.tests || []).filter((t) => t.test_file === file)
191
+ return tests.sort((a, b) => b.at.localeCompare(a.at))[0] || null
192
+ }
193
+
150
194
  /** Where browser_run leaves the earlier test for a goal that failed, so write_test updates it instead of adding one. */
151
195
  export const EXISTING_TEST_FILE = "existing-test.json"
196
+
197
+ /** Where browser_run leaves the earlier test ForgeQE's agent chose to extend, so write_test extends it. */
198
+ export const EXTEND_TEST_FILE = "extend-test.json"
@@ -78,7 +78,7 @@ export type ExistingTest = { testFile: string; command: string; output: string;
78
78
  * An existing test the job works from, chosen from measured coverage (or by the person): `reuse` it as it is, or
79
79
  * `extend` it with only the steps and checks it lacks.
80
80
  */
81
- export type TestTarget = { mode: "reuse" | "extend" | "update"; testFile: string; test: string; line: number; covered: string[]; missing: string[]; why: string }
81
+ export type TestTarget = { mode: "reuse" | "extend" | "update" | "sibling"; testFile: string; test: string; line: number; covered: string[]; missing: string[]; why: string }
82
82
 
83
83
  function targetSection(t: TestTarget): string[] {
84
84
  const list = (items: string[]) => items.map((i) => `- ${i}`)
@@ -91,6 +91,18 @@ function targetSection(t: TestTarget): string[] {
91
91
  "",
92
92
  ]
93
93
  }
94
+ if (t.mode === "sibling") {
95
+ return [
96
+ "## Add a test beside the existing one, do not change it",
97
+ "",
98
+ `${t.test} (${t.testFile}:${t.line}) covers the start of this journey (${t.why}). Add one new test method next to it, in the same class, for the flow below: reuse its setup, its helpers, and the page-object methods it calls for the steps the two share, and add only what the flow adds. Leave ${t.test} as it is. Do not create a new test file.`,
99
+ ...(t.covered.length ? ["", "The steps it already has, to reuse through the same methods:", ...t.covered.map((i) => `- ${i}`)] : []),
100
+ ...(t.missing.length ? ["", "What the new test adds:", ...t.missing.map((i) => `- ${i}`)] : []),
101
+ "",
102
+ "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).",
103
+ "",
104
+ ]
105
+ }
94
106
  if (t.mode === "reuse") {
95
107
  return [
96
108
  "## The existing test that already covers this flow",
@@ -100,16 +112,24 @@ function targetSection(t: TestTarget): string[] {
100
112
  "",
101
113
  ]
102
114
  }
115
+ const measured = t.covered.length
116
+ ? [
117
+ `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.`,
118
+ "",
119
+ "It already covers:",
120
+ ...list(t.covered),
121
+ "",
122
+ "Add only these:",
123
+ ...list(t.missing),
124
+ ]
125
+ : [
126
+ `${t.testFile} was chosen to extend (${t.why}): it covers the start of this journey. ForgeQE's code graph could not tell which of the run's steps it already does, so read it, keep every step and assertion it has, and add only the steps and checks of the run it lacks, in the order of the flow, through the same page objects and helpers. Do not create a new test file.`,
127
+ ...(t.missing.length ? ["", "The run's steps and checks:", ...list(t.missing)] : []),
128
+ ]
103
129
  return [
104
130
  "## Extend the existing test, do not write a new one",
105
131
  "",
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),
132
+ ...measured,
113
133
  "",
114
134
  "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
135
  "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.",
@@ -142,13 +162,15 @@ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; l
142
162
  : ""
143
163
  return [
144
164
  opts.existing
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.`
165
+ ? `Update the existing test so it does the flow below and passes. When it is updated, give the structured result; ForgeQE then runs it.`
146
166
  : opts.target?.mode === "reuse"
147
167
  ? `An existing test already covers the flow below. Report how to run it; change nothing. ForgeQE then runs it.`
148
168
  : opts.target?.mode === "extend"
149
169
  ? `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
170
  : opts.target?.mode === "update"
151
171
  ? `Update the existing test named below to match the flow below. When it is updated, give the structured result; ForgeQE then runs it.`
172
+ : opts.target?.mode === "sibling"
173
+ ? `Add a test for the flow below next to the existing test named below, in its class, reusing its setup and steps. When it is written, give the structured result; ForgeQE then runs it.`
152
174
  : `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.`,
153
175
  "",
154
176
  ...(opts.existing ? existingSection(opts.existing) : opts.target ? targetSection(opts.target) : []),
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { parseFile, type FileFacts, type Member } from "./graph/parse.ts"
5
+ import type { TestTarget } from "./prompt.ts"
6
+
7
+ /**
8
+ * What a job may do to the existing test it was given, checked in code rather than left to the prompt: extending keeps
9
+ * every line the test had, in order, and only adds; a test added beside it, or a test reused, leaves it as it was; and
10
+ * the test keeps its name. A live run showed why: asked to extend, Scribe changed the test's data values to the run's,
11
+ * and the reviewer approved it.
12
+ */
13
+
14
+ /** The existing test as the job found it: its method name and its lines. */
15
+ export type Original = { file: string; test: string; method: string; lines: string[] }
16
+
17
+ /** A line that is only punctuation says nothing about what the test does: braces, a lone semicolon, a closing paren. */
18
+ const SAYS_NOTHING = /^[{}()[\];,]*$/
19
+
20
+ const meaningful = (lines: string[]) => lines.map((l) => l.trim()).filter((l) => l && !SAYS_NOTHING.test(l))
21
+
22
+ function members(facts: FileFacts | null): { cls?: string; m: Member }[] {
23
+ if (!facts) return []
24
+ return [...facts.classes.flatMap((c) => c.members.map((m) => ({ cls: c.name, m }))), ...facts.functions.map((m) => ({ m }))]
25
+ }
26
+
27
+ /** The test's member in a parsed file: by Class.Method, or by its method name or title alone. */
28
+ function find(facts: FileFacts | null, test: string): Member | null {
29
+ const method = test.split(".").pop() || test
30
+ const all = members(facts).filter(({ m }) => m.kind === "test" || m.kind === "method")
31
+ return all.find(({ cls, m }) => `${cls}.${m.name}` === test)?.m || all.find(({ m }) => m.name === method)?.m || null
32
+ }
33
+
34
+ async function read(repo: string, file: string): Promise<{ text: string; facts: FileFacts | null } | null> {
35
+ try {
36
+ const text = readFileSync(path.join(repo, file), "utf8")
37
+ return { text, facts: await parseFile(file, text) }
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /** The target test's lines before the job starts; null when the file or the test cannot be read. */
44
+ export async function readOriginal(repo: string, target: Pick<TestTarget, "testFile" | "test">): Promise<Original | null> {
45
+ const got = await read(repo, target.testFile)
46
+ const m = got && find(got.facts, target.test)
47
+ if (!got || !m) return null
48
+ return { file: target.testFile, test: target.test, method: m.name, lines: got.text.split("\n").slice(m.line - 1, m.endLine) }
49
+ }
50
+
51
+ /** Lines of `before` missing from `after` when `after` must keep them in order (it may add lines between them). */
52
+ export function droppedLines(before: string[], after: string[]): string[] {
53
+ const keep = meaningful(before)
54
+ const now = meaningful(after)
55
+ const dropped: string[] = []
56
+ let j = 0
57
+ for (const line of keep) {
58
+ const at = now.indexOf(line, j)
59
+ if (at === -1) dropped.push(line)
60
+ else j = at + 1
61
+ }
62
+ return dropped
63
+ }
64
+
65
+ export type Breach = { why: string; lines: string[] }
66
+
67
+ /**
68
+ * How the job broke what its mode allows on the original test, or null when it did not. extend: no line dropped or
69
+ * changed; sibling and reuse: the test exactly as it was; every mode: the test still there under its name.
70
+ */
71
+ export async function checkOriginal(repo: string, original: Original, mode: TestTarget["mode"]): Promise<Breach | null> {
72
+ if (mode === "update") return null
73
+ const got = await read(repo, original.file)
74
+ const m = got && find(got.facts, original.method)
75
+ if (!got || !m) return { why: `the existing test ${original.test} is no longer in ${original.file} under that name`, lines: [] }
76
+ const now = got.text.split("\n").slice(m.line - 1, m.endLine)
77
+ if (mode === "extend") {
78
+ const dropped = droppedLines(original.lines, now)
79
+ return dropped.length ? { why: `lines of the existing test ${original.test} were changed or removed; extending keeps every line it had and only adds`, lines: dropped } : null
80
+ }
81
+ const changed = meaningful(original.lines).join("\n") !== meaningful(now).join("\n")
82
+ if (!changed) return null
83
+ const dropped = droppedLines(original.lines, now)
84
+ return {
85
+ why: mode === "sibling" ? `the existing test ${original.test} was changed; a test added beside it leaves it as it was` : `the existing test ${original.test} was changed; reusing it changes nothing`,
86
+ lines: dropped.length ? dropped : meaningful(now).filter((l) => !meaningful(original.lines).includes(l)),
87
+ }
88
+ }
89
+
90
+ /** The task for Scribe's next attempt: put the original test back as the mode requires. */
91
+ export function breachPrompt(original: Original, b: Breach, mode: TestTarget["mode"]): string {
92
+ return [
93
+ `ForgeQE checked the existing test and stopped before running it: ${b.why}.`,
94
+ ...(b.lines.length ? ["", mode === "extend" ? "These lines of it are missing or changed; put them back exactly as they were:" : "The test as it was had these lines:", "", "```", ...b.lines.slice(0, 20), "```"] : []),
95
+ "",
96
+ mode === "extend"
97
+ ? `Keep every step, value, and assertion of ${original.test} as it was, and add only the new steps and checks around them. When a value in the run differs from the test's, keep the test's value: the run's data was an example.`
98
+ : mode === "sibling"
99
+ ? `Leave ${original.test} as it was, and put the new flow in a new test method beside it in the same class.`
100
+ : `Leave ${original.test} as it was.`,
101
+ "Then give the structured result again.",
102
+ ].join("\n")
103
+ }
@@ -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
  }
@@ -133,6 +133,8 @@ export function reviewPrompt(input: ReviewInput, repo?: string): string {
133
133
  "",
134
134
  input.target.mode === "extend"
135
135
  ? `The job was asked to extend the existing test ${input.target.test} (${input.target.testFile}) with only what it lacked: ${input.target.missing.join("; ")}. Adding those steps and checks to that test is the requested change, not a rewrite. A step or assertion of it removed or weakened, those steps duplicated in a new test file, or a shared page-object member changed in a way its other users would notice, is a correctness issue.`
136
+ : input.target.mode === "sibling"
137
+ ? `The job was asked to add a test beside the existing test ${input.target.test} (${input.target.testFile}), in the same class, reusing its setup and the page-object methods for the steps they share. A change to ${input.target.test} itself, a new test file, or the shared steps written again instead of reused, is a correctness issue.`
136
138
  : input.target.mode === "update"
137
139
  ? `The user asked to update the existing test ${input.target.test} (${input.target.testFile}) to match this run. Changing its steps, locators, or expected values to match the run is the requested change. Removing an assertion the run still supports, or writing a new test file instead, is a correctness issue.`
138
140
  : `The job was asked to reuse the existing test ${input.target.test} (${input.target.testFile}) as it is, since it already covers the flow. Any change to it, or a new test, is a correctness issue.`,