@capagents/forgeqe 1.2.0 → 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 +15 -1
- package/docs/user-guide.html +13 -2
- package/package.json +1 -1
- package/prompts/brain.md +1 -0
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- 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/handoff.ts +20 -0
- package/src/code/loop.ts +10 -0
- package/src/code/replay.ts +33 -0
- 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 +2 -0
package/HOW_TO_USE.md
CHANGED
|
@@ -301,6 +301,8 @@ A card says which it did and why (*Extending PracticeTests.Flow · covers 12 of
|
|
|
301
301
|
|
|
302
302
|
**The same flow asked again.** When a session writes tests and you ask for a flow an earlier job already wrote a test for, ForgeQE runs that test first, before any browser run. If it passes in a real browser, that is the answer: no browser run, no Scribe, and no new test file, in the seconds the test takes (*✓ Existing test passed · 19s · no browser run or new test needed*). If it fails, the flow runs in the browser and Scribe updates that test instead of writing another. ForgeQE's agent rewords a goal a little each time, so goals count as the same flow when they share their content words (endings, case, and phrasing aside) and the new goal keeps everything the earlier one checked; the same steps with a different final check, a shorter flow, or another site are a new flow. The agent also compares the earlier goal with your request and runs fresh when you ask for a check the earlier test does not have. Ask for "a fresh run" or "a new test" to skip the reuse.
|
|
303
303
|
|
|
304
|
+
**When the flow cannot be reproduced.** If ForgeQE's own replay of the run could not reach the run's final page and Scribe's first test fails too, the job stops there and says which replayed step failed and why, instead of spending its remaining attempts on a flow no script can repeat as recorded.
|
|
305
|
+
|
|
304
306
|
**Why a test failed.** When ForgeQE's run of Scribe's test fails, the ✗ line and the next attempt say why in the test's own words: dotnet test's Error Message, pytest's `E` lines, or the error with its Expected and Received values (Playwright, Jest, JUnit), not the runner's summary line.
|
|
305
307
|
|
|
306
308
|
**Keeping Scribe's cost down.** ForgeQE reads the repository's test setup itself before Scribe starts (stack, settings files and their browser values, the test id attribute, base classes, page objects and their methods, one test command) and hands it over, so Scribe does not discover it with dozens of file reads. Each attempt after the first starts a fresh session from a summary of the job (handoff, files changed, what each attempt did, the latest failure), so later attempts cost about what the first did. `code.max_attempts` (default 3) and `code.max_tokens` (default 1,500,000; `0` turns it off) cap a job, and the token budget is checked during an attempt too, so one long attempt is stopped when the total passes it. The reviewer sorts its issues into correctness (the test does not test what the goal says, or not reliably) and coding standards (test data or credentials in a page object, naming, hard waits, leftovers). Coding standards never fail a test that works: it is accepted, and the coding agent gets one more round (not counted in `max_attempts`) to fix them; if that round breaks the test, ForgeQE puts back the version that passed and lists the standards issues in the notes (`code.tidy: false` skips the round). A test the reviewer rejects twice for correctness stops the job with its issues, since that usually means the test case and the page disagree. Under each step, the handoff shows that step's part of the page (its section and neighbours, from the replay's page snapshot) instead of whole snapshots. When a test fails, ForgeQE shows the failing element on the test's page at the failure and on the replay's page, so most failures are fixed without opening a browser. Scribe's live browser is switched on only when the same failure repeats, and it does not send a page snapshot after every click or keystroke. `code.max_steps` (default 50) caps the tool calls in one of Scribe's turns. `code.review_profile` runs the reviewer on a cheaper llms.json profile.
|
|
@@ -1210,7 +1212,19 @@ rag:
|
|
|
1210
1212
|
|
|
1211
1213
|
The answer can also be an object with `results`, `passages`, `documents`, or `hits`, and a passage's text can be `text`, `content`, or `page_content` (LangChain's shape). A RAG server with an MCP interface works through `mcp:` instead: ForgeQE's agent calls its tools.
|
|
1212
1214
|
|
|
1213
|
-
`suite.case_check` decides what happens before a run: `warn` (the default) shows the findings and runs every case, `block` leaves out the cases that cannot run as written, `off` skips the check.
|
|
1215
|
+
`suite.case_check` decides what happens before a run: `warn` (the default) shows the findings and runs every case, `block` leaves out the cases that cannot run as written, `off` skips the check. Ask to run a case and it runs, whatever the check found; ForgeQE's agent reports the findings with the result instead of refusing. A case whose steps have no expected results runs too: it passes when every step can be done, and the check lists it **to review**, since it checks no result the page should show.
|
|
1216
|
+
|
|
1217
|
+
### API steps in a test case
|
|
1218
|
+
|
|
1219
|
+
A test case can mix API and UI steps ("add three products with POST /add_to_cart/{id}, then remove the first one in the cart, then pay"). The browser agent makes each API step with its `api_request` action, from the page, in the browser's own session: the logged-in user's cookies go with the call, and the CSRF token comes from the cookie or the page's form when the site needs one. So what an API step changes (a cart, an order) is what the next UI step sees.
|
|
1220
|
+
|
|
1221
|
+
- **The verdict** of an API step rests on the status the server answered, not on the agent's account of it.
|
|
1222
|
+
- **The run records it** as an `api` step in `workflow.yaml`: method, URL, body, CSRF source, and the status and final URL the server gave.
|
|
1223
|
+
- **ForgeQE's replay repeats it** the same way, and fails that step when the server answers differently (403 where the run got 200), so the replay and the test get past it instead of stalling on an empty cart.
|
|
1224
|
+
- **Scribe's handoff** lists each API step with what was sent and answered, and tells Scribe to make it through the framework's API request context that shares the browser's cookies (Playwright's `page.request`), asserting the status.
|
|
1225
|
+
- **Kept for reuse**: the calls are saved as an API scenario, one per test case (`out/api/case-ado-2287/`, updated by each run), which `forgeqe api` and the API tools run again without a browser. The run's card says where (*API 2 calls saved as a reusable API scenario*).
|
|
1226
|
+
|
|
1227
|
+
URLs the case gives are opened exactly as given: when the model adds a trailing slash (`/login/`, a 404 on many sites), ForgeQE opens and records the URL from the case.
|
|
1214
1228
|
|
|
1215
1229
|
When a run finds a test case does not fit the application, the agent drafts the steps as they should be, from what the run found, and offers to save them. `update_test_case` shows the current and the new steps and asks before it changes the Azure DevOps Test Case; the reason goes into the work item's history, where the old steps stay.
|
|
1216
1230
|
|
package/docs/user-guide.html
CHANGED
|
@@ -590,6 +590,7 @@ forgeqe auth logout jira
|
|
|
590
590
|
</table></div>
|
|
591
591
|
<p>A card says which it did and why (<em>Extending PracticeTests.Flow · covers 12 of 15; only 3 are missing</em>). Say how when you know ("extend the login test", "write a separate test", "update it") and ForgeQE's agent passes that on without asking. In every mode Scribe adds page-object members rather than changing what an existing member does for the other tests that use it.</p>
|
|
592
592
|
<p><strong>The same flow asked again.</strong> When a session writes tests and you ask for a flow an earlier job already wrote a test for, ForgeQE runs that test first, before any browser run. If it passes in a real browser, that is the answer: no browser run, no Scribe, and no new test file, in the seconds the test takes (<em>✓ Existing test passed · 19s · no browser run or new test needed</em>). If it fails, the flow runs in the browser and Scribe updates that test instead of writing another. ForgeQE's agent rewords a goal a little each time, so goals count as the same flow when they share their content words (endings, case, and phrasing aside) and the new goal keeps everything the earlier one checked; the same steps with a different final check, a shorter flow, or another site are a new flow. The agent also compares the earlier goal with your request and runs fresh when you ask for a check the earlier test does not have. Ask for "a fresh run" or "a new test" to skip the reuse.</p>
|
|
593
|
+
<p><strong>When the flow cannot be reproduced.</strong> If ForgeQE's own replay of the run could not reach the run's final page and Scribe's first test fails too, the job stops there and says which replayed step failed and why, instead of spending its remaining attempts on a flow no script can repeat as recorded.</p>
|
|
593
594
|
<p><strong>Why a test failed.</strong> When ForgeQE's run of Scribe's test fails, the ✗ line and the next attempt say why in the test's own words: dotnet test's Error Message, pytest's <code>E</code> lines, or the error with its Expected and Received values (Playwright, Jest, JUnit), not the runner's summary line.</p>
|
|
594
595
|
<p><strong>Keeping Scribe's cost down.</strong> ForgeQE reads the repository's test setup itself before Scribe starts (stack, settings files and their browser values, the test id attribute, base classes, page objects and their methods, one test command) and hands it over, so Scribe does not discover it with dozens of file reads. Each attempt after the first starts a fresh session from a summary of the job (handoff, files changed, what each attempt did, the latest failure), so later attempts cost about what the first did. <code>code.max_attempts</code> (default 3) and <code>code.max_tokens</code> (default 1,500,000; <code>0</code> turns it off) cap a job, and the token budget is checked during an attempt too, so one long attempt is stopped when the total passes it. The reviewer sorts its issues into correctness (the test does not test what the goal says, or not reliably) and coding standards (test data or credentials in a page object, naming, hard waits, leftovers). Coding standards never fail a test that works: it is accepted, and the coding agent gets one more round (not counted in <code>max_attempts</code>) to fix them; if that round breaks the test, ForgeQE puts back the version that passed and lists the standards issues in the notes (<code>code.tidy: false</code> skips the round). A test the reviewer rejects twice for correctness stops the job with its issues, since that usually means the test case and the page disagree. Under each step, the handoff shows that step's part of the page (its section and neighbours, from the replay's page snapshot) instead of whole snapshots. When a test fails, ForgeQE shows the failing element on the test's page at the failure and on the replay's page, so most failures are fixed without opening a browser. Scribe's live browser is switched on only when the same failure repeats, and it does not send a page snapshot after every click or keystroke. <code>code.max_steps</code> (default 50) caps the tool calls in one of Scribe's turns. <code>code.review_profile</code> runs the reviewer on a cheaper llms.json profile.</p>
|
|
595
596
|
<p><code>forgeqe.yaml</code> selects the active profile:</p>
|
|
@@ -917,6 +918,17 @@ forgeqe memory clear booking.com --yes
|
|
|
917
918
|
<h4>Test cases and suites</h4>
|
|
918
919
|
<div class="prose"><p>Test cases you already have can be run two ways. In the shell or with <code>forgeqe run</code>, ask in plain words ("run SHOP-12 and SHOP-14", "run test plan 12 suite 34", "run the cases in smoke.yaml") or paste the cases, and the agent works out the source. <code>forgeqe suite</code> takes explicit references and runs the same way every time, with no agent involved, which is what pipelines call. Each case is one goal: ForgeQE tells the agent to do the steps in order and to check every expected result, and the agent's verdict decides whether the case passed. A case with no steps (plain text, a Jira story) is run from its description.</p>
|
|
919
920
|
</div>
|
|
921
|
+
<section class="doc sub" id="doc-how_to_use-api-steps-in-a-test-case"><h5>API steps in a test case</h5><div class="prose"><p>A test case can mix API and UI steps ("add three products with POST /add_to_cart/{id}, then remove the first one in the cart, then pay"). The browser agent makes each API step with its <code>api_request</code> action, from the page, in the browser's own session: the logged-in user's cookies go with the call, and the CSRF token comes from the cookie or the page's form when the site needs one. So what an API step changes (a cart, an order) is what the next UI step sees.</p>
|
|
922
|
+
<ul>
|
|
923
|
+
<li><strong>The verdict</strong> of an API step rests on the status the server answered, not on the agent's account of it.</li>
|
|
924
|
+
<li><strong>The run records it</strong> as an <code>api</code> step in <code>workflow.yaml</code>: method, URL, body, CSRF source, and the status and final URL the server gave.</li>
|
|
925
|
+
<li><strong>ForgeQE's replay repeats it</strong> the same way, and fails that step when the server answers differently (403 where the run got 200), so the replay and the test get past it instead of stalling on an empty cart.</li>
|
|
926
|
+
<li><strong>Scribe's handoff</strong> lists each API step with what was sent and answered, and tells Scribe to make it through the framework's API request context that shares the browser's cookies (Playwright's <code>page.request</code>), asserting the status.</li>
|
|
927
|
+
<li><strong>Kept for reuse</strong>: the calls are saved as an API scenario, one per test case (<code>out/api/case-ado-2287/</code>, updated by each run), which <code>forgeqe api</code> and the API tools run again without a browser. The run's card says where (<em>API 2 calls saved as a reusable API scenario</em>).</li>
|
|
928
|
+
</ul>
|
|
929
|
+
<p>URLs the case gives are opened exactly as given: when the model adds a trailing slash (<code>/login/</code>, a 404 on many sites), ForgeQE opens and records the URL from the case.</p>
|
|
930
|
+
<p>When a run finds a test case does not fit the application, the agent drafts the steps as they should be, from what the run found, and offers to save them. <code>update_test_case</code> shows the current and the new steps and asks before it changes the Azure DevOps Test Case; the reason goes into the work item's history, where the old steps stay.</p>
|
|
931
|
+
</div></section>
|
|
920
932
|
<section class="doc sub" id="doc-how_to_use-when-a-case-fails"><h5>When a case fails</h5><div class="prose"><p>The browser run of a test case is the test, so when a case does not pass, ForgeQE works out what kind of failure it was and acts on it. The browser agent reports a cause for each failed step (it saw the page); clear evidence in the run, such as an unreachable site or a 503 page, overrides "I could not operate it".</p>
|
|
921
933
|
<div class="tbl"><table>
|
|
922
934
|
<thead>
|
|
@@ -1148,8 +1160,7 @@ forgeqe suite cases.yaml --no-code --no-write-back --out .forgeqe/reports/smoke
|
|
|
1148
1160
|
# command: ["python", "rag/search.py"]
|
|
1149
1161
|
</code></pre>
|
|
1150
1162
|
<p>The answer can also be an object with <code>results</code>, <code>passages</code>, <code>documents</code>, or <code>hits</code>, and a passage's text can be <code>text</code>, <code>content</code>, or <code>page_content</code> (LangChain's shape). A RAG server with an MCP interface works through <code>mcp:</code> instead: ForgeQE's agent calls its tools.</p>
|
|
1151
|
-
<p><code>suite.case_check</code> decides what happens before a run: <code>warn</code> (the default) shows the findings and runs every case, <code>block</code> leaves out the cases that cannot run as written, <code>off</code> skips the check.</p>
|
|
1152
|
-
<p>When a run finds a test case does not fit the application, the agent drafts the steps as they should be, from what the run found, and offers to save them. <code>update_test_case</code> shows the current and the new steps and asks before it changes the Azure DevOps Test Case; the reason goes into the work item's history, where the old steps stay.</p>
|
|
1163
|
+
<p><code>suite.case_check</code> decides what happens before a run: <code>warn</code> (the default) shows the findings and runs every case, <code>block</code> leaves out the cases that cannot run as written, <code>off</code> skips the check. Ask to run a case and it runs, whatever the check found; ForgeQE's agent reports the findings with the result instead of refusing. A case whose steps have no expected results runs too: it passes when every step can be done, and the check lists it <strong>to review</strong>, since it checks no result the page should show.</p>
|
|
1153
1164
|
</div>
|
|
1154
1165
|
</section>
|
|
1155
1166
|
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/prompts/brain.md
CHANGED
|
@@ -160,6 +160,7 @@ Read data from Azure DevOps, Jira, Xray, Confluence, and other systems in this s
|
|
|
160
160
|
- run_test_cases files bugs for failed cases itself when "This session" says so, only for functional defects. You MUST NOT file them again. Its write_back list names each bug.
|
|
161
161
|
- run_test_cases checks the cases first (case_check in its result). When a case needs a review or cannot run as written, say so briefly, with the correction.
|
|
162
162
|
- When the user asks if cases are good, ready, clear, or complete, you MUST call check_test_cases.
|
|
163
|
+
- When the user asks to run cases, you MUST call run_test_cases, also when check_test_cases or its card found problems. run_test_cases skips a case only when the setup says so (suite.case_check: block). You MUST NOT refuse a run because of a check verdict. Report what the check found with the result, and offer to fix the case with update_test_case.
|
|
163
164
|
- check_test_cases also reads what each case tests: the story, the feature, the epic, linked Confluence pages, the RAG service, and page memory.
|
|
164
165
|
- You MUST judge each case against that. Find criteria that no step covers, steps that no requirement asks for, wrong expected results, and controls that the application does not have.
|
|
165
166
|
- For a case classified "test case needs updating", you MUST write the correct steps from run_details. Show them, and offer update_test_case.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: browser-api
|
|
3
|
+
description: "How the browser agent makes the API steps of a test case, in the browser's session, so they are recorded and repeated."
|
|
4
|
+
used_by: "the browser agent (every browser run)"
|
|
5
|
+
kind: fragment
|
|
6
|
+
---
|
|
7
|
+
## API steps
|
|
8
|
+
- For a step that calls an API (an HTTP method, an endpoint, or a payload), you MUST use api_request. You MUST NOT call fetch or XMLHttpRequest from evaluate.
|
|
9
|
+
- Before the first api_request, you MUST open a page of the site. api_request runs in the browser's session and sends its cookies.
|
|
10
|
+
- When the site needs a CSRF token, you MUST set csrf to "cookie" or "form". Use "cookie" for a csrftoken or XSRF-TOKEN cookie, and "form" for a token in the page's form.
|
|
11
|
+
- For a form payload, you MUST give body as name=value pairs joined with &, and content_type application/x-www-form-urlencoded. For JSON, you MUST give the JSON text and content_type application/json.
|
|
12
|
+
- You MUST judge an API step on the server's answer: its status, or a value in the response. You MUST NOT judge it on what the page shows. You MUST NOT record an API answer with record_check. The api_request result is the evidence of an API step. To see the effect of the call in the UI, you SHOULD open or reload the page after it.
|
|
13
|
+
- You MUST use every URL exactly as the task gives it. You MUST NOT add or remove a trailing slash, path segments, or parameters. When a URL that you changed fails, you MUST use the URL as given.
|
|
@@ -6,10 +6,10 @@ kind: fragment
|
|
|
6
6
|
---
|
|
7
7
|
## Acting so the run can become a test
|
|
8
8
|
ForgeQE turns a passed run into an automated test. The test repeats your actions with real clicks and typing.
|
|
9
|
-
- You MUST act only through the browser actions: click, input, select_dropdown, send_keys, and click_selector.
|
|
9
|
+
- You MUST act only through the browser actions: click, input, select_dropdown, send_keys, and click_selector, and through api_request for a step that calls an API.
|
|
10
10
|
- You MUST NOT click, submit, navigate, or change values from evaluate, for example with element.click(), dispatchEvent, `location.href = …`, or `.value`. A test cannot repeat these actions.
|
|
11
11
|
- You MAY use evaluate only to read the page.
|
|
12
|
-
- You MUST open a URL only for the start page, or when the task gives the URL.
|
|
12
|
+
- You MUST open a URL only for the start page, or when the task gives the URL. Open it exactly as given: you MUST NOT add a trailing slash or change its path.
|
|
13
13
|
- You MUST NOT add #section or ?parameters to a URL to go where a step takes you. That skips the step.
|
|
14
14
|
- When you open a wrong URL, you MUST go back to the start page. Then do the steps.
|
|
15
15
|
- When the control that you need has no index, you SHOULD find it with find_elements or evaluate. Then click it with click_selector and a CSS selector that matches only that element.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API steps in a browser run: api_request calls an HTTP endpoint from the page, in the browser's own session (its
|
|
3
|
+
cookies go with the call), so a test case can mix API and UI steps on one logged-in user. Each call is recorded as an
|
|
4
|
+
"api" step with its method, URL, body, and the response's status, so the verdict rests on the status the server
|
|
5
|
+
gave, the replay and the written test repeat the call, and ForgeQE keeps it as a reusable API scenario.
|
|
6
|
+
"""
|
|
7
|
+
# No `from __future__ import annotations`: the library matches the injected browser_session parameter by its real type.
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
from browser_use import ActionResult, BrowserSession
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("forgeqe_engine.api_step")
|
|
16
|
+
|
|
17
|
+
# An endpoint that does not answer in this time fails the step instead of holding the run.
|
|
18
|
+
CALL_TIMEOUT = 30.0
|
|
19
|
+
|
|
20
|
+
API_SYSTEM_MESSAGE = """
|
|
21
|
+
## API steps
|
|
22
|
+
- For a step that calls an API (an HTTP method, an endpoint, or a payload), you MUST use api_request. You MUST NOT call fetch or XMLHttpRequest from evaluate.
|
|
23
|
+
- Before the first api_request, you MUST open a page of the site. api_request runs in the browser's session and sends its cookies.
|
|
24
|
+
- When the site needs a CSRF token, you MUST set csrf to "cookie" or "form". Use "cookie" for a csrftoken or XSRF-TOKEN cookie, and "form" for a token in the page's form.
|
|
25
|
+
- For a form payload, you MUST give body as name=value pairs joined with &, and content_type application/x-www-form-urlencoded. For JSON, you MUST give the JSON text and content_type application/json.
|
|
26
|
+
- You MUST judge an API step on the server's answer: its status, or a value in the response. You MUST NOT judge it on what the page shows. You MUST NOT record an API answer with record_check. The api_request result is the evidence of an API step. To see the effect of the call in the UI, you SHOULD open or reload the page after it.
|
|
27
|
+
- You MUST use every URL exactly as the task gives it. You MUST NOT add or remove a trailing slash, path segments, or parameters. When a URL that you changed fails, you MUST use the URL as given.
|
|
28
|
+
""".strip()
|
|
29
|
+
|
|
30
|
+
# Runs in the page: the call carries the page's cookies; a CSRF token is read where the site keeps it.
|
|
31
|
+
_FETCH_JS = r"""async (a) => {
|
|
32
|
+
const cookie = (n) => { const c = document.cookie.split('; ').find((x) => x.startsWith(n + '=')); return c ? decodeURIComponent(c.slice(n.length + 1)) : '' };
|
|
33
|
+
let token = '';
|
|
34
|
+
if (a.csrf === 'cookie') token = cookie('csrftoken') || cookie('XSRF-TOKEN') || cookie('csrf_token') || cookie('_csrf');
|
|
35
|
+
if (a.csrf === 'form') {
|
|
36
|
+
const f = document.querySelector('input[name="csrfmiddlewaretoken"], input[name="_csrf"], input[name="csrf_token"], input[name="authenticity_token"], input[name="__RequestVerificationToken"]');
|
|
37
|
+
const m = document.querySelector('meta[name="csrf-token"], meta[name="_csrf"]');
|
|
38
|
+
token = (f && f.value) || (m && m.content) || '';
|
|
39
|
+
}
|
|
40
|
+
const headers = {};
|
|
41
|
+
if (a.contentType) headers['Content-Type'] = a.contentType;
|
|
42
|
+
let body = a.body || undefined;
|
|
43
|
+
if (token) {
|
|
44
|
+
headers['X-CSRFToken'] = token;
|
|
45
|
+
headers['X-XSRF-TOKEN'] = token;
|
|
46
|
+
headers['X-CSRF-Token'] = token;
|
|
47
|
+
// A form post carries the token in the form too, as the site's own form would.
|
|
48
|
+
if (/x-www-form-urlencoded/i.test(a.contentType || '') && !/(^|&)(csrfmiddlewaretoken|_csrf|authenticity_token)=/.test(body || '')) body = (body ? body + '&' : '') + 'csrfmiddlewaretoken=' + encodeURIComponent(token);
|
|
49
|
+
}
|
|
50
|
+
const method = (a.method || 'GET').toUpperCase();
|
|
51
|
+
try {
|
|
52
|
+
const r = await fetch(a.url, { method, headers, body: method === 'GET' || method === 'HEAD' ? undefined : body, credentials: 'include', redirect: 'follow' });
|
|
53
|
+
const text = await r.text();
|
|
54
|
+
return { status: r.status, ok: r.ok, final_url: r.url, redirected: r.redirected, content_type: r.headers.get('content-type') || '', body: text.slice(0, 4000), csrf_sent: Boolean(token) };
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return { error: String(e && e.message || e), page: location.href };
|
|
57
|
+
}
|
|
58
|
+
}"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _snippet(text: str, n: int = 300) -> str:
|
|
62
|
+
"""The start of a response body on one line: JSON as it is, HTML without its tags."""
|
|
63
|
+
s = text.strip()
|
|
64
|
+
if s.startswith("<"):
|
|
65
|
+
import re
|
|
66
|
+
|
|
67
|
+
title = re.search(r"<title[^>]*>(.*?)</title>", s, re.I | re.S)
|
|
68
|
+
s = (f"[HTML: {title.group(1).strip()}] " if title else "[HTML] ") + re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s))
|
|
69
|
+
return " ".join(s.split())[:n]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def describe(call: Dict[str, Any]) -> str:
|
|
73
|
+
""""POST https://shop.test/payment → 200, ended at https://shop.test/payment_done/1 · response: …" for the agent and the trace."""
|
|
74
|
+
if call.get("error"):
|
|
75
|
+
return f"{call['method']} {call['url']} failed: {call['error']}"
|
|
76
|
+
end = f", ended at {call['final_url']}" if call.get("redirected") and call.get("final_url") else ""
|
|
77
|
+
csrf = " (CSRF token sent)" if call.get("csrf_sent") else ""
|
|
78
|
+
return f"{call['method']} {call['url']}{csrf} → {call['status']}{end} · response: {call.get('snippet') or '(empty)'}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register_api_request(tools: Any, captured: Dict[str, Any]) -> None:
|
|
82
|
+
"""api_request: an HTTP call from the page's session; the call and its answer are captured for the step."""
|
|
83
|
+
|
|
84
|
+
@tools.action(
|
|
85
|
+
"Call an HTTP API endpoint from this browser's session (the session's cookies are sent) and get the status and "
|
|
86
|
+
"the start of the response. Use it for every step that calls an API; never fetch() from evaluate. "
|
|
87
|
+
"method: GET, POST, PUT, PATCH, or DELETE. url: exactly as the task gives it. body: name=value pairs joined with & "
|
|
88
|
+
"for a form, or JSON text. content_type: application/x-www-form-urlencoded or application/json. "
|
|
89
|
+
"csrf: empty, cookie, or form."
|
|
90
|
+
)
|
|
91
|
+
async def api_request(
|
|
92
|
+
method: str,
|
|
93
|
+
url: str,
|
|
94
|
+
browser_session: BrowserSession,
|
|
95
|
+
body: str = "",
|
|
96
|
+
content_type: str = "",
|
|
97
|
+
csrf: str = "",
|
|
98
|
+
) -> ActionResult:
|
|
99
|
+
args = {"method": (method or "GET").upper(), "url": url, "body": body, "contentType": content_type, "csrf": (csrf or "").lower()}
|
|
100
|
+
try:
|
|
101
|
+
cdp = await browser_session.get_or_create_cdp_session()
|
|
102
|
+
res = await asyncio.wait_for(
|
|
103
|
+
cdp.cdp_client.send.Runtime.evaluate(
|
|
104
|
+
params={"expression": f"({_FETCH_JS})({json.dumps(args)})", "awaitPromise": True, "returnByValue": True},
|
|
105
|
+
session_id=cdp.session_id,
|
|
106
|
+
),
|
|
107
|
+
timeout=CALL_TIMEOUT,
|
|
108
|
+
)
|
|
109
|
+
value = ((res or {}).get("result") or {}).get("value") or {}
|
|
110
|
+
except Exception as ex: # The page went away, or the call could not be made.
|
|
111
|
+
value = {"error": str(ex)}
|
|
112
|
+
call: Dict[str, Any] = {"method": args["method"], "url": url, "body": body, "content_type": content_type, "csrf": args["csrf"]}
|
|
113
|
+
if value.get("error"):
|
|
114
|
+
call["error"] = value["error"]
|
|
115
|
+
captured["api"] = call
|
|
116
|
+
hint = " Open a page of this site first: the call is made from the page." if str(value.get("page", "")).startswith("about:") else ""
|
|
117
|
+
return ActionResult(error=f"api_request: {describe(call)}.{hint}")
|
|
118
|
+
call.update(
|
|
119
|
+
status=int(value.get("status") or 0),
|
|
120
|
+
ok=bool(value.get("ok")),
|
|
121
|
+
final_url=str(value.get("final_url") or url),
|
|
122
|
+
redirected=bool(value.get("redirected")),
|
|
123
|
+
content_type_received=str(value.get("content_type") or ""),
|
|
124
|
+
csrf_sent=bool(value.get("csrf_sent")),
|
|
125
|
+
snippet=_snippet(str(value.get("body") or "")),
|
|
126
|
+
)
|
|
127
|
+
captured["api"] = call
|
|
128
|
+
line = describe(call)
|
|
129
|
+
# A 4xx or 5xx is the server's answer, not a failed action: the agent judges it against what the step expects.
|
|
130
|
+
return ActionResult(extracted_content=line, long_term_memory=line)
|
|
@@ -12,6 +12,7 @@ Live capture for the test a passed run becomes, done while the page is still the
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
|
+
import re
|
|
15
16
|
import time
|
|
16
17
|
from typing import Any, Dict, List, Optional
|
|
17
18
|
|
|
@@ -36,10 +37,10 @@ MAX_CLIMB = 5
|
|
|
36
37
|
CAPTURE_SYSTEM_MESSAGE = """
|
|
37
38
|
## Acting so the run can become a test
|
|
38
39
|
ForgeQE turns a passed run into an automated test. The test repeats your actions with real clicks and typing.
|
|
39
|
-
- You MUST act only through the browser actions: click, input, select_dropdown, send_keys, and click_selector.
|
|
40
|
+
- You MUST act only through the browser actions: click, input, select_dropdown, send_keys, and click_selector, and through api_request for a step that calls an API.
|
|
40
41
|
- You MUST NOT click, submit, navigate, or change values from evaluate, for example with element.click(), dispatchEvent, `location.href = …`, or `.value`. A test cannot repeat these actions.
|
|
41
42
|
- You MAY use evaluate only to read the page.
|
|
42
|
-
- You MUST open a URL only for the start page, or when the task gives the URL.
|
|
43
|
+
- You MUST open a URL only for the start page, or when the task gives the URL. Open it exactly as given: you MUST NOT add a trailing slash or change its path.
|
|
43
44
|
- You MUST NOT add #section or ?parameters to a URL to go where a step takes you. That skips the step.
|
|
44
45
|
- When you open a wrong URL, you MUST go back to the start page. Then do the steps.
|
|
45
46
|
- When the control that you need has no index, you SHOULD find it with find_elements or evaluate. Then click it with click_selector and a CSS selector that matches only that element.
|
|
@@ -114,6 +115,45 @@ def action_name(action: Any) -> str:
|
|
|
114
115
|
return next((k for k, v in data.items() if v is not None), "")
|
|
115
116
|
|
|
116
117
|
|
|
118
|
+
_URL = re.compile(r"https?://[^\s\"'<>)\]]+")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def task_urls(task: str) -> List[str]:
|
|
122
|
+
"""The URLs the task gives, without the punctuation that ends a sentence around them."""
|
|
123
|
+
return [u.rstrip(".,;:!?") for u in _URL.findall(task or "")]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def exact_task_url(url: str, given: Optional[List[str]]) -> Optional[str]:
|
|
127
|
+
"""
|
|
128
|
+
The task's own URL when `url` differs from it only by a trailing slash. Models add one (/login/), and on many
|
|
129
|
+
sites that is a 404: the URL the task gave is the one to open. None when `url` is fine as it is.
|
|
130
|
+
"""
|
|
131
|
+
if not url or not given or url in given:
|
|
132
|
+
return None
|
|
133
|
+
return next((t for t in given if t != url and t.rstrip("/") == url.rstrip("/")), None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def correct_task_urls(payload: Dict[str, Any], given: Optional[List[str]]) -> None:
|
|
137
|
+
"""
|
|
138
|
+
A step's actions, as shown and recorded, with the task's own URL where the model added a slash: what install_effects
|
|
139
|
+
opens. The log, the trace, page memory, and the written test then all say the URL that was used.
|
|
140
|
+
"""
|
|
141
|
+
actions = payload.get("actions") or []
|
|
142
|
+
for i, action in enumerate(actions):
|
|
143
|
+
for name in ("navigate", "api_request"):
|
|
144
|
+
params = action.get(name) if isinstance(action, dict) else None
|
|
145
|
+
if not isinstance(params, dict) or not params.get("url"):
|
|
146
|
+
continue
|
|
147
|
+
fixed = exact_task_url(str(params["url"]), given)
|
|
148
|
+
if not fixed:
|
|
149
|
+
continue
|
|
150
|
+
for key in ("action_lines", "plain_actions"):
|
|
151
|
+
lines = payload.get(key) or []
|
|
152
|
+
if i < len(lines) and isinstance(lines[i], str):
|
|
153
|
+
lines[i] = lines[i].replace(str(params["url"]), fixed)
|
|
154
|
+
params["url"] = fixed
|
|
155
|
+
|
|
156
|
+
|
|
117
157
|
def install_effects(tools: Any, effects: List[Dict[str, Any]], captured: Dict[str, Any]) -> None:
|
|
118
158
|
"""
|
|
119
159
|
Wrap tools.act so each action call leaves {action, effect} in `effects`, in call order; click_selector also leaves
|
|
@@ -124,14 +164,25 @@ def install_effects(tools: Any, effects: List[Dict[str, Any]], captured: Dict[st
|
|
|
124
164
|
async def act(action: Any, browser_session: Any = None, *args: Any, **kwargs: Any):
|
|
125
165
|
session = browser_session if browser_session is not None else kwargs.get("browser_session")
|
|
126
166
|
name = action_name(action)
|
|
167
|
+
# A URL the task gave, opened with a slash it does not have: open it exactly as given.
|
|
168
|
+
if name in ("navigate", "api_request"):
|
|
169
|
+
# The library's ActionModel is a root model: the action's parameters sit under .root.
|
|
170
|
+
params = getattr(getattr(action, "root", action), name, None)
|
|
171
|
+
fixed = exact_task_url(str(getattr(params, "url", "") or ""), captured.get("task_urls"))
|
|
172
|
+
if fixed and params is not None:
|
|
173
|
+
logger.info("%s: using the task's URL %s instead of %s", name, fixed, params.url)
|
|
174
|
+
params.url = fixed
|
|
127
175
|
before = await page_state(session) if name in EFFECT_ACTIONS and session is not None else None
|
|
128
176
|
captured.pop("target", None)
|
|
177
|
+
captured.pop("api", None)
|
|
129
178
|
result = await original(action, browser_session, *args, **kwargs)
|
|
130
179
|
entry: Dict[str, Any] = {"action": name, "effect": None}
|
|
131
180
|
if name in EFFECT_ACTIONS and session is not None and not getattr(result, "error", None):
|
|
132
181
|
entry["effect"] = await effect_after(session, before)
|
|
133
182
|
if captured.get("target") is not None:
|
|
134
183
|
entry["target"] = captured.pop("target")
|
|
184
|
+
if captured.get("api") is not None:
|
|
185
|
+
entry["api"] = captured.pop("api")
|
|
135
186
|
effects.append(entry)
|
|
136
187
|
return result
|
|
137
188
|
|
|
@@ -1001,4 +1001,15 @@ def _compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1001
1001
|
return {"op": "back"}
|
|
1002
1002
|
if action == "evaluate":
|
|
1003
1003
|
return {"op": "evaluate", "code": str(p.get("code") or "")[:2000]}
|
|
1004
|
+
if action == "api_request":
|
|
1005
|
+
# An HTTP call made in the page's session: what was sent, and what the server answered when the run made it.
|
|
1006
|
+
out = {"op": "api", "method": str(p.get("method") or "GET").upper(), "url": p.get("url") or ""}
|
|
1007
|
+
for k in ("body", "content_type", "csrf"):
|
|
1008
|
+
if p.get(k):
|
|
1009
|
+
out[k] = p[k]
|
|
1010
|
+
answer = step.get("api") or {}
|
|
1011
|
+
for k in ("status", "final_url", "redirected", "snippet"):
|
|
1012
|
+
if answer.get(k) not in (None, "", False):
|
|
1013
|
+
out[k] = answer[k]
|
|
1014
|
+
return out
|
|
1004
1015
|
return {"op": "unknown", "action_keys": [action]}
|
|
@@ -432,6 +432,12 @@ class MemoryRun:
|
|
|
432
432
|
return data
|
|
433
433
|
|
|
434
434
|
def add(self, step: Dict[str, Any], agent_step: int = 0) -> None:
|
|
435
|
+
# A navigate redone at once (a wrong URL, then the right one) leaves only the one that stood: the flow, its
|
|
436
|
+
# replay, and the test then never open the page that was a mistake.
|
|
437
|
+
if step.get("action") == "navigate" and self.steps and self.steps[-1].get("action") == "navigate":
|
|
438
|
+
self.steps[-1] = step
|
|
439
|
+
self.step_of[-1] = agent_step
|
|
440
|
+
return
|
|
435
441
|
self.steps.append(step)
|
|
436
442
|
self.step_of.append(agent_step)
|
|
437
443
|
|
|
@@ -521,6 +527,14 @@ def _plan_memory_actions(
|
|
|
521
527
|
if not name:
|
|
522
528
|
continue
|
|
523
529
|
params = action[name] if isinstance(action[name], dict) else {}
|
|
530
|
+
# The URL the task gave, where the model added a slash: what was opened (see capture.install_effects), recorded.
|
|
531
|
+
if name in ("navigate", "api_request") and params.get("url"):
|
|
532
|
+
from .capture import exact_task_url, task_urls
|
|
533
|
+
|
|
534
|
+
fixed = exact_task_url(str(params["url"]), task_urls(getattr(mem, "goal", "") or ""))
|
|
535
|
+
if fixed:
|
|
536
|
+
line = line.replace(str(params["url"]), fixed)
|
|
537
|
+
params = {**params, "url": fixed}
|
|
524
538
|
target = None
|
|
525
539
|
if params.get("index") is not None:
|
|
526
540
|
try:
|
|
@@ -606,6 +620,8 @@ def _record_memory_results(
|
|
|
606
620
|
step["overlay"] = target["overlay"]
|
|
607
621
|
if name == "evaluate" and eff.get("effect") is True:
|
|
608
622
|
step["scripted"] = True
|
|
623
|
+
if name == "api_request" and eff.get("api"):
|
|
624
|
+
step["api"] = {k: v for k, v in eff["api"].items() if k in ("status", "final_url", "redirected", "snippet", "csrf_sent")}
|
|
609
625
|
if note:
|
|
610
626
|
step["note"] = note
|
|
611
627
|
mem.add(step, agent_step)
|
|
@@ -756,7 +772,8 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
756
772
|
|
|
757
773
|
from browser_use import Agent, Browser, Tools
|
|
758
774
|
|
|
759
|
-
from .
|
|
775
|
+
from .api_step import API_SYSTEM_MESSAGE, register_api_request
|
|
776
|
+
from .capture import CAPTURE_SYSTEM_MESSAGE, correct_task_urls, install_effects, register_click_selector, task_urls
|
|
760
777
|
from .checks import CHECKS_SYSTEM_MESSAGE, register_checks
|
|
761
778
|
from .skills import choose_skills, skills_block
|
|
762
779
|
|
|
@@ -875,6 +892,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
875
892
|
|
|
876
893
|
async def _on_new_step(browser_state: Any, agent_output: Any, n_steps: int) -> None:
|
|
877
894
|
payload = serialize_browser_step_payload(browser_state, agent_output, n_steps + replay_offset)
|
|
895
|
+
correct_task_urls(payload, captured.get("task_urls"))
|
|
878
896
|
collected_steps.append(payload)
|
|
879
897
|
_evaluate_control_loop()
|
|
880
898
|
emit({"type": "step", **{k: v for k, v in payload.items() if k not in ("actions", "boxes")}})
|
|
@@ -961,7 +979,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
961
979
|
handoff = handoff_task(goal, flow, replay, kind=kind)
|
|
962
980
|
emit({"type": "memory", "text": "The agent checks the result" if replay.complete and kind == "exact" else "The agent takes over from here"})
|
|
963
981
|
|
|
964
|
-
extra_parts: List[str] = [prompt_text("browser-checks", CHECKS_SYSTEM_MESSAGE), prompt_text("browser-capture", CAPTURE_SYSTEM_MESSAGE)]
|
|
982
|
+
extra_parts: List[str] = [prompt_text("browser-checks", CHECKS_SYSTEM_MESSAGE), prompt_text("browser-capture", CAPTURE_SYSTEM_MESSAGE), prompt_text("browser-api", API_SYSTEM_MESSAGE)]
|
|
965
983
|
login_block = build_default_browser_login_system_extension()
|
|
966
984
|
if login_block:
|
|
967
985
|
extra_parts.append(login_block)
|
|
@@ -1000,7 +1018,9 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1000
1018
|
)
|
|
1001
1019
|
agent_tools = Tools()
|
|
1002
1020
|
register_checks(agent_tools, recorded_checks)
|
|
1021
|
+
captured["task_urls"] = task_urls(req.goal)
|
|
1003
1022
|
register_click_selector(agent_tools, captured)
|
|
1023
|
+
register_api_request(agent_tools, captured)
|
|
1004
1024
|
install_effects(agent_tools, action_effects, captured)
|
|
1005
1025
|
agent_kw["tools"] = agent_tools
|
|
1006
1026
|
agent_kw.update(_browser_use_agent_tuning_kwargs())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml"
|
|
5
|
+
|
|
6
|
+
import type { AppConfig } from "../config.ts"
|
|
7
|
+
import { scenarioFromRun } from "./scenario.ts"
|
|
8
|
+
import { saveSet } from "./store.ts"
|
|
9
|
+
import { contractsFromTraffic, readNetwork, type NetworkCall } from "./traffic.ts"
|
|
10
|
+
import type { ContractSet } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The API steps a browser run made (api_request, in the browser's session) kept in ForgeQE's own API format: a
|
|
14
|
+
* contract set with one scenario that makes the calls in the run's order, carrying values from one answer to the next.
|
|
15
|
+
* It runs again without a browser (forgeqe api run, or ForgeQE's agent), and the API tools plan from it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type ApiStep = { op: "api"; method?: string; url?: string; body?: string; content_type?: string; csrf?: string; status?: number; final_url?: string; snippet?: string }
|
|
19
|
+
|
|
20
|
+
export type SavedApiSteps = { id: string; dir: string; file: string; calls: number }
|
|
21
|
+
|
|
22
|
+
function apiSteps(runDir: string): ApiStep[] {
|
|
23
|
+
const file = path.join(runDir, "workflow.yaml")
|
|
24
|
+
if (!existsSync(file)) return []
|
|
25
|
+
try {
|
|
26
|
+
const wf = YAML.parse(readFileSync(file, "utf8")) as { steps?: { op?: string }[] }
|
|
27
|
+
return (wf?.steps || []).filter((s): s is ApiStep => s?.op === "api" && typeof (s as ApiStep).url === "string")
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const samePath = (a: string, b: string) => {
|
|
34
|
+
try {
|
|
35
|
+
const x = new URL(a)
|
|
36
|
+
const y = new URL(b)
|
|
37
|
+
return x.host === y.host && x.pathname.replace(/\/$/, "") === y.pathname.replace(/\/$/, "")
|
|
38
|
+
} catch {
|
|
39
|
+
return a === b
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Each API step with the call network.json recorded for it (its headers and the whole answer), in order; a step the
|
|
45
|
+
* capture did not see (capture off, or over its limit) is kept from what the step itself recorded.
|
|
46
|
+
*/
|
|
47
|
+
export function callsForSteps(steps: ApiStep[], network: NetworkCall[]): NetworkCall[] {
|
|
48
|
+
const used = new Set<NetworkCall>()
|
|
49
|
+
const ordered = [...network].sort((a, b) => a.t - b.t)
|
|
50
|
+
return steps.map((s, i) => {
|
|
51
|
+
const method = (s.method || "GET").toUpperCase()
|
|
52
|
+
const hit = ordered.find((c) => !used.has(c) && c.method.toUpperCase() === method && samePath(c.url, s.url!))
|
|
53
|
+
if (hit) {
|
|
54
|
+
used.add(hit)
|
|
55
|
+
return hit
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
t: i,
|
|
59
|
+
method,
|
|
60
|
+
url: s.url!,
|
|
61
|
+
type: "Fetch",
|
|
62
|
+
request: { ...(s.content_type ? { headers: { "content-type": s.content_type } } : {}), ...(s.body ? { body: s.body } : {}) },
|
|
63
|
+
response: { status: s.status || 0, ...(s.snippet ? { body: s.snippet } : {}) },
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Saves the run's API steps as a contract set with their scenario; null when the run made no API step. */
|
|
69
|
+
/** "case-ado-2287", or "flow-" and a short hash of the goal: stable across runs of the same case or flow. */
|
|
70
|
+
export function setIdFor(caseId: string | undefined, goal: string): string {
|
|
71
|
+
if (caseId) return `case-${caseId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
|
|
72
|
+
let h = 0
|
|
73
|
+
for (const ch of goal) h = (h * 31 + ch.charCodeAt(0)) | 0
|
|
74
|
+
return `flow-${(h >>> 0).toString(36)}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function saveApiSteps(config: AppConfig, runDir: string, opts: { caseId?: string; caseTitle?: string } = {}): SavedApiSteps | null {
|
|
78
|
+
const steps = apiSteps(runDir)
|
|
79
|
+
if (!steps.length) return null
|
|
80
|
+
const calls = callsForSteps(steps, readNetwork(runDir)).filter((c) => c.response?.status)
|
|
81
|
+
if (!calls.length) return null
|
|
82
|
+
const { contracts, skipped, baseUrl } = contractsFromTraffic(calls, { thirdParty: true })
|
|
83
|
+
let goal = ""
|
|
84
|
+
try {
|
|
85
|
+
goal = (JSON.parse(readFileSync(path.join(runDir, "run.json"), "utf8")) as { goal?: string }).goal || ""
|
|
86
|
+
} catch {}
|
|
87
|
+
// One set per test case (or per flow), updated by each run: the latest calls, not a new copy every time.
|
|
88
|
+
const id = setIdFor(opts.caseId, goal || path.basename(runDir))
|
|
89
|
+
for (const c of contracts) c.source.ref = path.basename(runDir)
|
|
90
|
+
const set: ContractSet = {
|
|
91
|
+
version: 1,
|
|
92
|
+
id,
|
|
93
|
+
name: opts.caseId ? `API steps of ${opts.caseId}${opts.caseTitle ? `: ${opts.caseTitle}` : ""}` : `API steps of: ${goal.slice(0, 80) || path.basename(runDir)}`,
|
|
94
|
+
baseUrl,
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
source: { kind: "run", ref: runDir },
|
|
97
|
+
contracts,
|
|
98
|
+
...(skipped.length ? { skipped } : {}),
|
|
99
|
+
}
|
|
100
|
+
const scenario = scenarioFromRun(calls, set, { goal, runId: path.basename(runDir), thirdParty: true })
|
|
101
|
+
if (scenario) {
|
|
102
|
+
scenario.notes = [
|
|
103
|
+
...(scenario.notes || []),
|
|
104
|
+
"The run made these calls in the browser's logged-in session. Run without a browser, they need that session: a login step first, or its cookie and CSRF token in api headers.",
|
|
105
|
+
]
|
|
106
|
+
if (opts.caseId) Object.assign(scenario, { caseId: opts.caseId, ...(opts.caseTitle ? { caseTitle: opts.caseTitle } : {}) })
|
|
107
|
+
set.scenarios = [scenario]
|
|
108
|
+
}
|
|
109
|
+
const saved = saveSet(config, set)
|
|
110
|
+
return { id, dir: saved.dir, file: saved.file, calls: calls.length }
|
|
111
|
+
}
|
package/src/cases/quality.ts
CHANGED
|
@@ -54,7 +54,9 @@ export function checkCase(tc: TestCase): CaseFinding[] {
|
|
|
54
54
|
}
|
|
55
55
|
const withExpected = steps.filter((s) => s.expected?.trim())
|
|
56
56
|
if (!withExpected.length) {
|
|
57
|
-
|
|
57
|
+
// It still runs: each step is done and verified, so the case passes when every step can be done. What it does not
|
|
58
|
+
// have is a result to check, so it is worth fixing, not a reason to refuse it.
|
|
59
|
+
out.push({ code: "no_expected_results", severity: "warning", message: "No step has an expected result: the run passes when every step can be done, and checks no result the page should show.", suggestion: "Give at least the steps that matter an expected result the page shows (a message, a value, a page)." })
|
|
58
60
|
}
|
|
59
61
|
steps.forEach((s, i) => {
|
|
60
62
|
const n = i + 1
|
package/src/code/handoff.ts
CHANGED
|
@@ -43,6 +43,16 @@ export type Step = {
|
|
|
43
43
|
scripted?: boolean
|
|
44
44
|
/** click: the control sat in an overlay (cookie banner, sign-in prompt, dialog) that this click dismissed. */
|
|
45
45
|
overlay?: { kind?: string; name?: string; id?: string }
|
|
46
|
+
/** api: an HTTP call made in the browser's session, and what the server answered when the run made it. */
|
|
47
|
+
method?: string
|
|
48
|
+
body?: string
|
|
49
|
+
content_type?: string
|
|
50
|
+
/** api: where the CSRF token came from: the "cookie", or the page's "form". */
|
|
51
|
+
csrf?: string
|
|
52
|
+
status?: number
|
|
53
|
+
final_url?: string
|
|
54
|
+
redirected?: boolean
|
|
55
|
+
snippet?: string
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
/** An element the engine found on the live page, with its locators (verified ones first). */
|
|
@@ -174,6 +184,10 @@ function stepLines(step: Step, n: number): string[] {
|
|
|
174
184
|
break
|
|
175
185
|
case "press":
|
|
176
186
|
return [`${n}. press ${step.keys}`]
|
|
187
|
+
case "api":
|
|
188
|
+
return [
|
|
189
|
+
`${n}. API ${step.method || "GET"} ${step.url}${step.body ? ` with ${step.content_type?.includes("json") ? "JSON" : "form"} body ${quote(step.body.slice(0, 300))}` : ""}${step.csrf ? `, CSRF token from the ${step.csrf === "form" ? "page's form" : "csrftoken cookie"}` : ""}: the server answered ${step.status ?? "?"}${step.redirected && step.final_url ? `, ending at ${step.final_url}` : ""}${step.snippet ? `; response starts: ${quote(step.snippet.slice(0, 160))}` : ""}`,
|
|
190
|
+
]
|
|
177
191
|
case "evaluate":
|
|
178
192
|
return step.scripted
|
|
179
193
|
? [
|
|
@@ -558,6 +572,12 @@ export function buildHandoff(runDir: string, memoryDir?: string, opts: { replay?
|
|
|
558
572
|
"",
|
|
559
573
|
)
|
|
560
574
|
}
|
|
575
|
+
if (steps.some((s) => s.op === "api")) {
|
|
576
|
+
md.push(
|
|
577
|
+
"Steps marked API are HTTP calls the run made in the browser's own session (its cookies went with each call, so a login and a cart carry over between the API and the UI steps). Make each one in the test the same way: through the framework's API request context that shares the browser's cookies (Playwright: page.request in TypeScript, Page.APIRequest in .NET, page.request in Python and Java), with the same method, URL, and body, reading the CSRF token where the step says (the page's form, or the csrftoken cookie) and sending it as the site expects. Assert the status the run got. Do not replace an API step with clicks, or a UI step with an API call.",
|
|
578
|
+
"",
|
|
579
|
+
)
|
|
580
|
+
}
|
|
561
581
|
const slices = stepSlices(steps, opts.replay)
|
|
562
582
|
if (slices.size) {
|
|
563
583
|
md.push(
|
package/src/code/loop.ts
CHANGED
|
@@ -605,6 +605,16 @@ export async function runCodeAgent(opts: {
|
|
|
605
605
|
break
|
|
606
606
|
}
|
|
607
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
|
+
}
|
|
608
618
|
const mismatch = browserMismatch(runBrowser, run.browsers, report.command)
|
|
609
619
|
if (mismatch) emit({ type: "code", kind: "info", text: `The test's browser differs from the run's: ${run.browsers.join(", ")}` })
|
|
610
620
|
const key = failureKey(run.output)
|
package/src/code/replay.ts
CHANGED
|
@@ -135,11 +135,31 @@ function describe(step: Step): string {
|
|
|
135
135
|
return "go back"
|
|
136
136
|
case "evaluate":
|
|
137
137
|
return "run the agent's JavaScript"
|
|
138
|
+
case "api":
|
|
139
|
+
return `API ${step.method || "GET"} ${step.url}`
|
|
138
140
|
default:
|
|
139
141
|
return `${step.op || "unknown"}${step.action_keys?.length ? ` (${step.action_keys.join(", ")})` : ""}`
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* An API step as the engine's api_request makes it (py/forgeqe_engine/api_step.py): fetch in the page with its cookies,
|
|
147
|
+
* the CSRF token from the cookie or the page's form, sent in headers and, for a form post, in the form.
|
|
148
|
+
*/
|
|
149
|
+
export const API_CALL_JS = `async (a) => {
|
|
150
|
+
const cookie = (n) => { const c = document.cookie.split('; ').find((x) => x.startsWith(n + '=')); return c ? decodeURIComponent(c.slice(n.length + 1)) : '' };
|
|
151
|
+
let token = '';
|
|
152
|
+
if (a.csrf === 'cookie') token = cookie('csrftoken') || cookie('XSRF-TOKEN') || cookie('csrf_token') || cookie('_csrf');
|
|
153
|
+
if (a.csrf === 'form') { const f = document.querySelector('input[name="csrfmiddlewaretoken"], input[name="_csrf"], input[name="csrf_token"], input[name="authenticity_token"], input[name="__RequestVerificationToken"]'); const m = document.querySelector('meta[name="csrf-token"], meta[name="_csrf"]'); token = (f && f.value) || (m && m.content) || ''; }
|
|
154
|
+
const headers = {};
|
|
155
|
+
if (a.contentType) headers['Content-Type'] = a.contentType;
|
|
156
|
+
let body = a.body || undefined;
|
|
157
|
+
if (token) { headers['X-CSRFToken'] = token; headers['X-XSRF-TOKEN'] = token; headers['X-CSRF-Token'] = token; if (/x-www-form-urlencoded/i.test(a.contentType || '') && !/(^|&)(csrfmiddlewaretoken|_csrf|authenticity_token)=/.test(body || '')) body = (body ? body + '&' : '') + 'csrfmiddlewaretoken=' + encodeURIComponent(token); }
|
|
158
|
+
const method = (a.method || 'GET').toUpperCase();
|
|
159
|
+
try { const r = await fetch(a.url, { method, headers, body: method === 'GET' || method === 'HEAD' ? undefined : body, credentials: 'include', redirect: 'follow' }); return { status: r.status, final_url: r.url }; }
|
|
160
|
+
catch (e) { return { error: String(e && e.message || e) }; }
|
|
161
|
+
}`
|
|
162
|
+
|
|
143
163
|
/** The body of one step in the script, or null when it has no Playwright equivalent. */
|
|
144
164
|
function stepCode(step: Step): string | null {
|
|
145
165
|
const loc = step.locators?.map(locatorExpr).find(Boolean) || null
|
|
@@ -171,6 +191,19 @@ function stepCode(step: Step): string | null {
|
|
|
171
191
|
return "(page) => page.goBack()"
|
|
172
192
|
case "evaluate":
|
|
173
193
|
return step.code ? `(page) => page.evaluate(${q(step.code)})` : null
|
|
194
|
+
case "api": {
|
|
195
|
+
if (!step.url) return null
|
|
196
|
+
// The call is made in the page, as the run made it: the session's cookies and its CSRF token go with it. The step
|
|
197
|
+
// fails when the server answers differently than it did in the run (403 where the run got 200).
|
|
198
|
+
const args = JSON.stringify({ method: step.method || "GET", url: step.url, body: step.body || "", contentType: step.content_type || "", csrf: step.csrf || "" })
|
|
199
|
+
return [
|
|
200
|
+
"async (page) => {",
|
|
201
|
+
` const r = await page.evaluate(${API_CALL_JS}, ${args})`,
|
|
202
|
+
" if (r.error) throw new Error(`API ${r.error}`)",
|
|
203
|
+
...(step.status ? [` if (r.status !== ${Number(step.status)}) throw new Error(\`API answered \${r.status}, the run got ${Number(step.status)}\`)`] : []),
|
|
204
|
+
" }",
|
|
205
|
+
].join("\n")
|
|
206
|
+
}
|
|
174
207
|
default:
|
|
175
208
|
return null
|
|
176
209
|
}
|
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. */
|