@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/code/review.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { loadSkills } from "../skills/registry.ts"
|
|
|
11
11
|
import type { Price } from "../usage.ts"
|
|
12
12
|
import { prompt } from "../prompts.ts"
|
|
13
13
|
import { domainNotes } from "../domain.ts"
|
|
14
|
+
import type { TestTarget } from "./prompt.ts"
|
|
14
15
|
|
|
15
16
|
const OUTPUT_CHARS = 4_000
|
|
16
17
|
/** Changed files are shown in the prompt up to these sizes; the reviewer reads the rest itself. */
|
|
@@ -78,6 +79,8 @@ export type ReviewInput = {
|
|
|
78
79
|
outlines?: string
|
|
79
80
|
/** The repository's browser settings files and their values, as ForgeQE read them. */
|
|
80
81
|
settings?: string[]
|
|
82
|
+
/** The existing test the job was asked to reuse or extend, from measured coverage or the user's choice. */
|
|
83
|
+
target?: TestTarget
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
const LIST_MAX = 60
|
|
@@ -125,6 +128,16 @@ export function reviewPrompt(input: ReviewInput, repo?: string): string {
|
|
|
125
128
|
`The handoff it worked from: ${handoff.file}`,
|
|
126
129
|
...(checks ? ["", "The browser agent's checks, from the handoff:", checks] : []),
|
|
127
130
|
"",
|
|
131
|
+
...(input.target
|
|
132
|
+
? [
|
|
133
|
+
"",
|
|
134
|
+
input.target.mode === "extend"
|
|
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 === "update"
|
|
137
|
+
? `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
|
+
: `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.`,
|
|
139
|
+
]
|
|
140
|
+
: []),
|
|
128
141
|
`Test file: ${report.test_file}`,
|
|
129
142
|
`Command: ${report.command}`,
|
|
130
143
|
input.changes
|
package/src/mcp/commands.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { coverageLines, EXTEND_FROM, measureCoverage, type Candidate } from "../code/coverage.ts"
|
|
2
|
+
import { buildGraph } from "../code/graph/graph.ts"
|
|
3
|
+
import { EXISTING_TEST_FILE } from "../code/notes.ts"
|
|
4
|
+
import type { ExistingTest, TestTarget } from "../code/prompt.ts"
|
|
1
5
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
6
|
import path from "node:path"
|
|
3
7
|
|
|
@@ -27,6 +31,62 @@ const clip = (s: string | undefined, n: number) => {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** Marks a browser_run that was a task (a lookup in a tool), not a flow to automate; write_test refuses such runs. */
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reuse, extend, or new, from what the repository's tests already cover of the run (code graph and page memory).
|
|
37
|
+
* The clear cases go ahead and say why; where the evidence fits more than one way, the user picks, seeing each
|
|
38
|
+
* option's effect. A mode the user gave (through ForgeQE's agent) is followed without asking.
|
|
39
|
+
*/
|
|
40
|
+
export async function chooseTarget(runDir: string, repo: string, args: Record<string, any>, ctx: ToolContext): Promise<{ target?: TestTarget } | { cancelled: true }> {
|
|
41
|
+
const asked = typeof args.mode === "string" && args.mode !== "auto" ? (args.mode as "new" | "extend" | "reuse" | "update") : null
|
|
42
|
+
if (asked === "new") return {}
|
|
43
|
+
const graph = await buildGraph(repo).catch(() => null)
|
|
44
|
+
if (!graph) return {}
|
|
45
|
+
const cov = measureCoverage(runDir, graph, ctx.config.memory.dir)
|
|
46
|
+
const named = args.test_file ? cov.candidates.find((c) => c.file === String(args.test_file).replace(/^\.\//, "")) : undefined
|
|
47
|
+
let decision = cov.decision
|
|
48
|
+
if (asked) decision = { mode: asked, ask: false, target: named || cov.candidates[0], why: "the user asked for it" }
|
|
49
|
+
const lines = coverageLines(cov)
|
|
50
|
+
const toTarget = (mode: TestTarget["mode"], c: Candidate): TestTarget => ({
|
|
51
|
+
mode,
|
|
52
|
+
testFile: c.file,
|
|
53
|
+
test: c.test,
|
|
54
|
+
line: c.line,
|
|
55
|
+
covered: c.covered.map((i) => cov.items[i]!.label),
|
|
56
|
+
missing: c.missing.map((i) => cov.items[i]!.label),
|
|
57
|
+
why: `it covers ${c.covered.length} of ${c.covered.length + c.missing.length} steps and checks this run located`,
|
|
58
|
+
})
|
|
59
|
+
const say = (title: string, why: string) =>
|
|
60
|
+
ctx.emit?.({ type: "card", card: { status: "info", title, stats: why, body: lines.length ? ["What the repository's tests already cover of this run:", ...lines] : [] } })
|
|
61
|
+
if (decision.ask && ctx.ask) {
|
|
62
|
+
const options = cov.candidates.filter((c) => c.share >= EXTEND_FROM).slice(0, 2).flatMap((c, n) => {
|
|
63
|
+
const name = `${path.basename(c.file)}`
|
|
64
|
+
return c.missing.length
|
|
65
|
+
? [{ id: `extend:${n}`, label: `Extend ${name} (adds ${c.missing.length})` }]
|
|
66
|
+
: [{ id: `reuse:${n}`, label: `Reuse ${name} as it is` }]
|
|
67
|
+
})
|
|
68
|
+
const answer = await ctx.ask({
|
|
69
|
+
title: "Existing tests already cover this flow. Which way should Scribe go?",
|
|
70
|
+
detail: `${decision.why}. Extending keeps the test's steps and adds only what is missing; a new test duplicates what they cover.`,
|
|
71
|
+
lines,
|
|
72
|
+
options: [...options, { id: "new", label: "Write a new test" }, { id: "cancel", label: "Cancel" }],
|
|
73
|
+
})
|
|
74
|
+
if (!answer || answer === "cancel") return { cancelled: true }
|
|
75
|
+
if (answer === "new") return {}
|
|
76
|
+
const [mode, n] = answer.split(":") as ["reuse" | "extend", string]
|
|
77
|
+
const c = cov.candidates.filter((x) => x.share >= EXTEND_FROM)[Number(n)]!
|
|
78
|
+
say(mode === "reuse" ? `Reusing ${c.test}` : `Extending ${c.test}`, "the user chose it")
|
|
79
|
+
return { target: toTarget(mode, c) }
|
|
80
|
+
}
|
|
81
|
+
if (decision.mode === "new" || !decision.target) {
|
|
82
|
+
if (cov.candidates.length) say("Writing a new test", decision.why)
|
|
83
|
+
return {}
|
|
84
|
+
}
|
|
85
|
+
const mode: TestTarget["mode"] = decision.mode === "reuse" ? "reuse" : decision.mode === "update" ? "update" : "extend"
|
|
86
|
+
say(mode === "reuse" ? `Reusing ${decision.target.test}` : mode === "update" ? `Updating ${decision.target.test}` : `Extending ${decision.target.test}`, decision.why)
|
|
87
|
+
return { target: toTarget(mode, decision.target) }
|
|
88
|
+
}
|
|
89
|
+
|
|
30
90
|
export const TASK_FILE = "task.json"
|
|
31
91
|
|
|
32
92
|
const inCwd = (ctx: ToolContext, p: unknown) => (p ? path.resolve(ctx.cwd, String(p)) : ctx.cwd)
|
|
@@ -96,6 +156,13 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
96
156
|
properties: {
|
|
97
157
|
run_dir: { type: "string", description: "Run folder from browser_run or run_test_cases (default: the latest run)" },
|
|
98
158
|
repo: { type: "string", description: "Repository to write the test in, relative to the working directory (default: the working directory)" },
|
|
159
|
+
mode: {
|
|
160
|
+
type: "string",
|
|
161
|
+
enum: ["auto", "new", "extend", "reuse", "update"],
|
|
162
|
+
description:
|
|
163
|
+
"auto (default): ForgeQE measures which existing tests already cover the run, then reuses one that covers it all, extends one that covers most, or writes a new test, and asks the user when that is not clear. new, extend, reuse, update: only when the user said which (\"extend the login test\", \"write a separate test\", \"update it\" after a reused test failed).",
|
|
164
|
+
},
|
|
165
|
+
test_file: { type: "string", description: "With mode extend, reuse, or update: the existing test file (from the user, or from an earlier write_test result)" },
|
|
99
166
|
},
|
|
100
167
|
},
|
|
101
168
|
async run(args, ctx) {
|
|
@@ -108,6 +175,12 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
108
175
|
const t0 = Date.now()
|
|
109
176
|
const repo = inCwd(ctx, args.repo)
|
|
110
177
|
const goal = (readJson(path.join(runDir, "run.json")) as { goal?: string } | null)?.goal || ""
|
|
178
|
+
// browser_run found an earlier test for this flow and it failed: Scribe updates it rather than adding another.
|
|
179
|
+
const existingTest = readJson(path.join(runDir, EXISTING_TEST_FILE)) as ExistingTest | null
|
|
180
|
+
// Otherwise: what the repository's tests already cover of this run decides reuse, extend, or new.
|
|
181
|
+
const chosen = existingTest?.testFile ? { target: undefined } : await chooseTarget(runDir, repo, args, ctx)
|
|
182
|
+
if ("cancelled" in chosen) return { status: "cancelled", notes: "The user cancelled before Scribe started; nothing was changed." }
|
|
183
|
+
const target = chosen.target
|
|
111
184
|
ctx.emit?.({ type: "code-start", runDir, repo, goal })
|
|
112
185
|
let r
|
|
113
186
|
try {
|
|
@@ -119,6 +192,8 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
119
192
|
onEvent: (event) => ctx.emit?.({ type: "agent", event }),
|
|
120
193
|
open: ctx.openSession as never,
|
|
121
194
|
runTest: ctx.runTest,
|
|
195
|
+
...(existingTest?.testFile ? { existingTest } : {}),
|
|
196
|
+
...(target ? { target } : {}),
|
|
122
197
|
})
|
|
123
198
|
} catch (err) {
|
|
124
199
|
ctx.emit?.({ type: "code-done", error: (err as Error).message, ms: Date.now() - t0 })
|
|
@@ -136,6 +211,7 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
136
211
|
notes: r.notes || undefined,
|
|
137
212
|
browsers: r.browsers,
|
|
138
213
|
review: r.review,
|
|
214
|
+
...(r.existing ? { existing: r.existing } : {}),
|
|
139
215
|
output: r.status === "passed" ? undefined : clip(r.output, 4000),
|
|
140
216
|
}
|
|
141
217
|
},
|
package/src/mcp/tools.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
4
|
import type { AgentEvent, CodeEvent, CodeResult, ReplayMode, RunResult } from "../types.ts"
|
|
@@ -16,6 +16,7 @@ import { buildHandoff } from "../code/handoff.ts"
|
|
|
16
16
|
import { latestRunDir, runCodeAgent } from "../code/index.ts"
|
|
17
17
|
import type { OpencodeSession } from "../code/runtime.ts"
|
|
18
18
|
import { runTestCommand } from "../code/testrun.ts"
|
|
19
|
+
import { EXISTING_TEST_FILE, findWrittenTest } from "../code/notes.ts"
|
|
19
20
|
import { browserProvider, type AppConfig } from "../config.ts"
|
|
20
21
|
import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
|
|
21
22
|
import { executeRun, runDirectory } from "../run.ts"
|
|
@@ -329,6 +330,59 @@ function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
|
|
|
329
330
|
return [...new Set(devices)]
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Runs the test an earlier job wrote for this goal, if there is one. `passed`: the tool's answer, the test having
|
|
335
|
+
* passed in a browser. `failed`: the test to update, for write_test. Null when there is no such test.
|
|
336
|
+
*/
|
|
337
|
+
async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext): Promise<{ passed?: Record<string, unknown>; failed?: { testFile: string; command: string; output: string; why: string } } | null> {
|
|
338
|
+
const cfg = ctx.config
|
|
339
|
+
if (!cfg.memory.enabled) return null
|
|
340
|
+
let repo = ctx.cwd
|
|
341
|
+
try {
|
|
342
|
+
repo = realpathSync(ctx.cwd)
|
|
343
|
+
} catch {}
|
|
344
|
+
const known = findWrittenTest(cfg.memory.dir, repo, goal, (f) => existsSync(path.resolve(repo, f)))
|
|
345
|
+
if (!known) return null
|
|
346
|
+
const emit = ctx.emit || (() => {})
|
|
347
|
+
emit({ type: "info", text: `This flow already has a test, ${known.test_file} (written ${known.at.slice(0, 10)}): ForgeQE runs it first` })
|
|
348
|
+
mkdirSync(runDir, { recursive: true })
|
|
349
|
+
const run = await (ctx.runTest || runTestCommand)({
|
|
350
|
+
command: known.command,
|
|
351
|
+
cwd: repo,
|
|
352
|
+
timeoutSeconds: cfg.code.testTimeoutSeconds,
|
|
353
|
+
signal: ctx.signal,
|
|
354
|
+
hide: privateEnvNames(cfg),
|
|
355
|
+
save: path.join(runDir, "existing-test"),
|
|
356
|
+
})
|
|
357
|
+
if (ctx.signal?.aborted) return null
|
|
358
|
+
const secs = Math.round(run.ms / 1000)
|
|
359
|
+
if (run.ok && run.browsers.length) {
|
|
360
|
+
emit({
|
|
361
|
+
type: "card",
|
|
362
|
+
card: {
|
|
363
|
+
status: "ok",
|
|
364
|
+
title: "Existing test passed",
|
|
365
|
+
stats: `${secs}s · no browser run or new test needed`,
|
|
366
|
+
body: [`Test ${known.test_file}`, `Command ${known.command}`, `Browser ${run.browsers.join(", ")}`, `Written ${known.at.slice(0, 10)} for the same flow`],
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
return {
|
|
370
|
+
passed: {
|
|
371
|
+
status: "passed",
|
|
372
|
+
reused_test: known.test_file,
|
|
373
|
+
written_for: known.goal,
|
|
374
|
+
command: known.command,
|
|
375
|
+
browsers: run.browsers,
|
|
376
|
+
seconds: secs,
|
|
377
|
+
note: "An earlier job wrote a test for this flow (written_for) and it passed as it is, in a real browser: no browser run and no new test were needed. If the user's request asks for something written_for does not check, call browser_run again with fresh: true; otherwise say in one or two lines that the existing test passed.",
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const why = run.ok ? "It exits 0 but opened no browser." : `It fails now (${run.exitCode === null ? "timed out" : `exit ${run.exitCode}`}): the application or the page changed since it was written.`
|
|
382
|
+
emit({ type: "info", text: `${known.test_file} ${run.ok ? "opened no browser" : "fails now"}: ForgeQE runs the flow in the browser, and Scribe updates that test instead of writing a new one` })
|
|
383
|
+
return { failed: { testFile: known.test_file, command: known.command, output: run.output, why } }
|
|
384
|
+
}
|
|
385
|
+
|
|
332
386
|
export const TOOLS: McpTool[] = [
|
|
333
387
|
{
|
|
334
388
|
name: "browser_run",
|
|
@@ -362,6 +416,11 @@ export const TOOLS: McpTool[] = [
|
|
|
362
416
|
description:
|
|
363
417
|
"test: a user flow of the app under test, to verify and turn into an automated test. task: anything else done in a browser, such as looking up or linking work items, checking a relationship, or reading data from a tool; no test is written for it.",
|
|
364
418
|
},
|
|
419
|
+
fresh: {
|
|
420
|
+
type: "boolean",
|
|
421
|
+
description:
|
|
422
|
+
"true: run in the browser even when an earlier job wrote a test for this goal. Only when the user asks for a new browser run or a new test; by default an existing test is run first and reused when it passes.",
|
|
423
|
+
},
|
|
365
424
|
},
|
|
366
425
|
required: ["goal", "purpose"],
|
|
367
426
|
},
|
|
@@ -378,6 +437,10 @@ export const TOOLS: McpTool[] = [
|
|
|
378
437
|
if (devices.length > 1) return runGoalOnDevices(goal, url, devices, args, ctx)
|
|
379
438
|
const runDir = runDirectory(ctx.config.export.outDir)
|
|
380
439
|
const emit = ctx.emit || (() => {})
|
|
440
|
+
// The same flow asked again: an earlier job's test for it runs first. Passing in a browser, it answers the goal
|
|
441
|
+
// in seconds, with no browser run and no new test; failing, the run goes ahead and Scribe updates that test.
|
|
442
|
+
const reuse = ctx.writeTests && args.purpose !== "task" && !args.fresh && !resumeFrom && devices.length <= 1 ? await existingTestFirst(goal, runDir, ctx) : null
|
|
443
|
+
if (reuse?.passed) return reuse.passed
|
|
381
444
|
const t0 = Date.now()
|
|
382
445
|
emit({ type: "run-start", goal, url })
|
|
383
446
|
try {
|
|
@@ -406,7 +469,11 @@ export const TOOLS: McpTool[] = [
|
|
|
406
469
|
return summary
|
|
407
470
|
}
|
|
408
471
|
if (ctx.writeTests && result.status === "complete" && result.driver !== "synthetic") {
|
|
409
|
-
|
|
472
|
+
if (reuse?.failed) writeFileSync(path.join(runDir, EXISTING_TEST_FILE), `${JSON.stringify(reuse.failed, null, 2)}\n`)
|
|
473
|
+
return {
|
|
474
|
+
...summary,
|
|
475
|
+
next_step: `This session turns passed runs into tests: call write_test with run_dir ${runDir} now, before you reply.${reuse?.failed ? ` It updates the existing test ${reuse.failed.testFile} instead of writing a new one.` : ""}`,
|
|
476
|
+
}
|
|
410
477
|
}
|
|
411
478
|
return summary
|
|
412
479
|
} catch (err) {
|
package/src/run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { saveApiSteps } from "./api/fromSteps.ts"
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
3
|
import { createRequire } from "node:module"
|
|
3
4
|
import path from "node:path"
|
|
@@ -23,6 +24,8 @@ import { knowledgeBlock, readKnowledge, relevantKnowledge } from "./knowledge.ts
|
|
|
23
24
|
export type ExecuteOptions = {
|
|
24
25
|
config: AppConfig
|
|
25
26
|
goal: string
|
|
27
|
+
/** The test case the run is for: its API steps are saved as that case's scenario. */
|
|
28
|
+
caseRef?: { caseId?: string; caseTitle?: string }
|
|
26
29
|
browserProvider?: BrowserProvider
|
|
27
30
|
/** A named device from the provider files (browser_devices); it also picks the provider. */
|
|
28
31
|
browserDevice?: string
|
|
@@ -156,6 +159,13 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
156
159
|
const withReport = (out: { result: RunResult; files: string[] }) => {
|
|
157
160
|
out.result.cost = costOf(out.result.usage, opts.config.llm.price)
|
|
158
161
|
applyQuality(opts.config, dir, out.result, opts.onEvent)
|
|
162
|
+
// API steps the run made in the browser's session, kept as a scenario the API tools run again without a browser.
|
|
163
|
+
try {
|
|
164
|
+
const api = saveApiSteps(opts.config, dir, opts.caseRef || {})
|
|
165
|
+
if (api) out.result.api = { id: api.id, dir: api.dir, calls: api.calls }
|
|
166
|
+
} catch (err) {
|
|
167
|
+
opts.onEvent?.({ type: "log", level: "warn", text: `API steps not saved as a scenario: ${(err as Error).message}` })
|
|
168
|
+
}
|
|
159
169
|
logEvent("run.done", { out_dir: dir, status: out.result.status, summary: out.result.summary, usage: out.result.usage, memory: out.result.memory, ...(out.result.skills ? { skills: out.result.skills } : {}) })
|
|
160
170
|
try {
|
|
161
171
|
out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed, browser }), t0))
|
package/src/suite/report.ts
CHANGED
|
@@ -77,11 +77,28 @@ export function suiteUsage(results: CaseResult[], analysis?: { usage: Usage; cos
|
|
|
77
77
|
return { usage, cost: priced ? cost : undefined }
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The suite's headline, saying what each part did: the cases, and the tests written from them. A suite whose cases
|
|
82
|
+
* all passed but whose test did not pass is not called failed with "1/1 passed" beside it.
|
|
83
|
+
*/
|
|
84
|
+
export function suiteHeadline(s: Pick<SuiteResult, "results" | "stopped">): { mark: "✓" | "✗" | "■"; title: string; counts: string; ok: boolean } {
|
|
85
|
+
const c = counts(s.results)
|
|
86
|
+
const coded = s.results.filter((r) => r.code)
|
|
87
|
+
const testsOk = coded.filter((r) => r.code!.status === "passed").length
|
|
88
|
+
const casesOk = c.passed === c.total
|
|
89
|
+
const testsLine = coded.length ? ` · ${testsOk}/${coded.length} ${coded.length === 1 ? "test" : "tests"} passing` : ""
|
|
90
|
+
const countsLine = `${c.passed}/${c.total} ${c.total === 1 ? "case" : "cases"} passed${testsLine}`
|
|
91
|
+
if (s.stopped) return { mark: "■", title: "Suite stopped", counts: countsLine, ok: false }
|
|
92
|
+
if (!casesOk) return { mark: "✗", title: "Suite failed", counts: countsLine, ok: false }
|
|
93
|
+
if (testsOk < coded.length) return { mark: "✗", title: `${c.total === 1 ? "Case" : "Cases"} passed, ${coded.length - testsOk === 1 ? "test" : "tests"} not passing`, counts: countsLine, ok: false }
|
|
94
|
+
return { mark: "✓", title: "Suite passed", counts: countsLine, ok: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
/** The end-of-suite summary for the terminal. */
|
|
81
98
|
export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
|
|
82
|
-
const c = counts(s.results)
|
|
83
99
|
const tokens = s.results.reduce((n, r) => n + r.tokens, 0)
|
|
84
|
-
const
|
|
100
|
+
const h = suiteHeadline(s)
|
|
101
|
+
const head = `${h.mark} ${h.title} · ${h.counts} · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
|
|
85
102
|
const show = (f: string) => path.relative(cwd, f) || f
|
|
86
103
|
const total = suiteUsage(s.results, s.analysis)
|
|
87
104
|
const lines = [head, ""]
|
|
@@ -116,7 +133,8 @@ export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
|
|
|
116
133
|
export function markdownSummary(s: SuiteResult): string {
|
|
117
134
|
const c = counts(s.results)
|
|
118
135
|
const cell = (t: string) => t.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()
|
|
119
|
-
const
|
|
136
|
+
const h = suiteHeadline(s)
|
|
137
|
+
const head = `${h.mark} ${h.title}`
|
|
120
138
|
const rows = s.results.map((r) => {
|
|
121
139
|
const test = r.code ? `${r.code.status}${r.code.testFile ? ` \`${cell(r.code.testFile)}\`` : ""}` : ""
|
|
122
140
|
const steps = r.stepResults?.length && r.case.steps.length ? `${r.stepResults.filter((v) => v.status === "passed").length}/${r.case.steps.length}` : ""
|
|
@@ -140,7 +158,7 @@ export function markdownSummary(s: SuiteResult): string {
|
|
|
140
158
|
]
|
|
141
159
|
: []
|
|
142
160
|
return [
|
|
143
|
-
`### ${head} · ${
|
|
161
|
+
`### ${head} · ${h.counts} · ${duration(s.ms)}`,
|
|
144
162
|
"",
|
|
145
163
|
"| | Case | Title | Steps | Time | Result | Test |",
|
|
146
164
|
"|---|---|---|---|---|---|---|",
|
package/src/suite/run.ts
CHANGED
|
@@ -151,6 +151,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
151
151
|
const { result } = await execute({
|
|
152
152
|
config,
|
|
153
153
|
goal: goalFor(original),
|
|
154
|
+
caseRef: { caseId: original.id, caseTitle: original.title },
|
|
154
155
|
browserProvider: opts.browserProvider,
|
|
155
156
|
browserDevice: device,
|
|
156
157
|
url: tc.startUrl || opts.url,
|
package/src/summary.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type RunSummary = {
|
|
|
25
25
|
usage: string
|
|
26
26
|
/** WCAG rules passed and failed, Core Web Vitals, and whether the budgets held. */
|
|
27
27
|
quality?: string
|
|
28
|
+
/** The API steps saved as a reusable scenario: "2 calls saved as API scenario 20261013-…" and where. */
|
|
29
|
+
api?: string
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export const STATUS_TITLE: Record<RunStatus, string> = {
|
|
@@ -80,6 +82,7 @@ export function buildSummary(opts: {
|
|
|
80
82
|
failedChecks?: { expected: string; actual: string }[]
|
|
81
83
|
quality?: QualityReport
|
|
82
84
|
budgetExceeded?: string[]
|
|
85
|
+
api?: { id: string; dir: string; calls: number }
|
|
83
86
|
}): RunSummary {
|
|
84
87
|
const dir = opts.files.length ? path.relative(process.cwd(), path.dirname(opts.files[0])) || "." : ""
|
|
85
88
|
const assertion = opts.outcome === "assertion_failed"
|
|
@@ -97,6 +100,7 @@ export function buildSummary(opts: {
|
|
|
97
100
|
savedFiles: opts.files.map((f) => path.basename(f)),
|
|
98
101
|
usage: opts.usage ? usageLine(opts.usage, opts.cost) : "",
|
|
99
102
|
...(opts.quality && qualityLine(opts.quality) ? { quality: qualityLine(opts.quality) } : {}),
|
|
103
|
+
...(opts.api ? { api: `${opts.api.calls} ${opts.api.calls === 1 ? "call" : "calls"} saved as a reusable API scenario · ${path.relative(process.cwd(), opts.api.dir) || opts.api.dir}/` } : {}),
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -106,6 +110,7 @@ export function formatSummary(s: RunSummary): string {
|
|
|
106
110
|
if (s.finalUrl) lines.push(`Ended on ${s.finalUrl}`)
|
|
107
111
|
if (s.memory) lines.push(`Memory ${s.memory}`)
|
|
108
112
|
if (s.quality) lines.push(`Quality ${s.quality}`)
|
|
113
|
+
if (s.api) lines.push(`API ${s.api}`)
|
|
109
114
|
if (s.usage) lines.push(`Usage ${s.usage}`)
|
|
110
115
|
if (s.savedDir) lines.push(`Saved ${s.savedDir}/ ${s.savedFiles.join(" · ")}`)
|
|
111
116
|
return lines.join("\n")
|
package/src/tui/App.tsx
CHANGED
|
@@ -893,6 +893,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
893
893
|
failedChecks: r?.failedChecks,
|
|
894
894
|
quality: r?.quality,
|
|
895
895
|
budgetExceeded: r?.budgetExceeded,
|
|
896
|
+
api: r?.api,
|
|
896
897
|
ms: e.ms,
|
|
897
898
|
tokens: r ? r.usage.input + r.usage.output : 0,
|
|
898
899
|
steps: stepRef.current,
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { createContext, useContext, type ReactNode } from "react"
|
|
|
6
6
|
|
|
7
7
|
import type { CodeResult, RunStatus } from "../types.ts"
|
|
8
8
|
import { CARD_MARK, type Card } from "../card.ts"
|
|
9
|
-
import { counts } from "../suite/report.ts"
|
|
9
|
+
import { counts, suiteHeadline } from "../suite/report.ts"
|
|
10
10
|
import { suiteExitCode, type SuiteResult } from "../suite/run.ts"
|
|
11
11
|
import { STATUS_MARK, shortPath, type RunSummary } from "../summary.ts"
|
|
12
12
|
import { BAR } from "./components.tsx"
|
|
@@ -192,6 +192,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
|
|
|
192
192
|
{summary.finalUrl ? detail("Ended on", <span fg={theme.muted}>{summary.finalUrl}</span>) : null}
|
|
193
193
|
{summary.memory ? detail("Memory", <span fg={theme.muted}>{summary.memory}</span>) : null}
|
|
194
194
|
{summary.quality ? detail("Quality", <span fg={theme.muted}>{summary.quality}</span>) : null}
|
|
195
|
+
{summary.api ? detail("API", <Linked text={summary.api} color={theme.muted} lead={UNDER + 10} />) : null}
|
|
195
196
|
{summary.savedDir ? (
|
|
196
197
|
<text>
|
|
197
198
|
<span fg={theme.faint}>{"Saved "}</span>
|
|
@@ -272,7 +273,7 @@ export function Row({ label, value, color = theme.muted }: { label: string; valu
|
|
|
272
273
|
export const CASE_MARK = { passed: "✓", failed: "✗", error: "!", stopped: "■" } as const
|
|
273
274
|
|
|
274
275
|
export function SuiteCard({ suite }: { suite: SuiteResult }) {
|
|
275
|
-
const
|
|
276
|
+
const head = suiteHeadline(suite)
|
|
276
277
|
const ok = !suite.stopped && suiteExitCode(suite) === 0
|
|
277
278
|
const color = suite.stopped ? theme.warn : ok ? theme.ok : theme.err
|
|
278
279
|
const tokens = suite.results.reduce((n, r) => n + r.tokens, 0)
|
|
@@ -283,9 +284,9 @@ export function SuiteCard({ suite }: { suite: SuiteResult }) {
|
|
|
283
284
|
<Marked mark={MARK.item} color={color} gap>
|
|
284
285
|
<text>
|
|
285
286
|
<span fg={theme.text}>
|
|
286
|
-
<strong>{
|
|
287
|
+
<strong>{head.title}</strong>
|
|
287
288
|
</span>
|
|
288
|
-
<span fg={theme.faint}>{` ${
|
|
289
|
+
<span fg={theme.faint}>{` ${head.counts} · ${formatDuration(suite.ms)} · ${formatTokens(tokens)} tokens`}</span>
|
|
289
290
|
</text>
|
|
290
291
|
</Marked>
|
|
291
292
|
<Under first>
|
package/src/types.ts
CHANGED
|
@@ -59,6 +59,8 @@ export type RunResult = {
|
|
|
59
59
|
quality?: QualityReport
|
|
60
60
|
/** Budgets this run exceeded: the run counts as failed, though its steps passed. */
|
|
61
61
|
budgetExceeded?: string[]
|
|
62
|
+
/** The run's API steps, saved as a reusable API scenario: where, and how many calls. */
|
|
63
|
+
api?: { id: string; dir: string; calls: number }
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** Why a failed step failed, in the browser agent's judgement: see src/cases/failure.ts. */
|
|
@@ -116,6 +118,8 @@ export type CodeResult = {
|
|
|
116
118
|
knownDefect?: boolean
|
|
117
119
|
/** The job did not finish: what it wrote was moved to the needs-attention folder (see src/code/attention.ts). */
|
|
118
120
|
attention?: { dir: string; files: string[]; patch?: string; readme: string }
|
|
121
|
+
/** The job worked from an existing test: ran it as it was, extended it, or updated it. */
|
|
122
|
+
existing?: "reused" | "extended" | "updated"
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
/** What an agent did to a file, for the shell's file list. */
|