@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 CHANGED
@@ -288,6 +288,23 @@ A profile's own `"price": { "input": 2.5, "output": 15, "cached_input": 0.25 }`
288
288
  This request used 1.1M tokens (1.1M in, 30.0K of it cached · 34.7K out) · $3.07 — ForgeQE's agent 42.0K · $0.12, browser agent 52.7K · $0.15, Scribe 1.0M · $2.80
289
289
  ```
290
290
 
291
+ **Reuse before rebuild, extend before duplicate.** Before Scribe writes anything, `write_test` measures what the repository's tests already cover of the run, from evidence rather than guesswork: each step and check the browser agent made on an element is matched, through page memory, to the page-object member that locates it, and the code graph says which tests reach that member (through the page methods they call, or a property they assert, such as `Expect(practicePage.AjaxStatus)`). Steps on elements no page object locates yet are listed as not measured, since a test may do them its own way. Then:
292
+
293
+ | What it finds | What happens |
294
+ |---|---|
295
+ | One test covers every measured step and check | **Reuse**: Scribe only finds the command that runs it, and changes nothing. If it fails, nothing is changed: you are asked whether to update it or write a new test. |
296
+ | One test covers most (40% or more) | **Extend**: Scribe adds only the missing steps and checks to that test, keeping every step and assertion it has; the reviewer rejects a removed or weakened assertion, or the steps copied into a new file |
297
+ | Two or more tests cover most of it | **You choose**: the evidence and the options (reuse, extend with how many steps it adds, a new test, cancel) |
298
+ | Nothing covers much | **New**: a new test, reusing the page objects and helpers as before |
299
+
300
+ A card says which it did and why (*Extending PracticeTests.Flow · covers 12 of 15; only 3 are missing*). 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.
301
+
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
+
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
+
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.
307
+
291
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.
292
309
 
293
310
  `forgeqe.yaml` selects the active profile:
@@ -1195,7 +1212,19 @@ rag:
1195
1212
 
1196
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.
1197
1214
 
1198
- `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.
1199
1228
 
1200
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.
1201
1230
 
@@ -576,6 +576,22 @@ forgeqe auth logout jira
576
576
  <p><strong>Usage after every job.</strong> Every browser run, Scribe job, heal, suite, and monitor round ends with a <code>Usage</code> line: tokens in (and how many of them were cached), tokens out, and the cost at the profile's price. A request to ForgeQE's agent in the shell, and every <code>forgeqe run</code>, <code>code</code>, <code>suite</code>, and <code>heal</code> command, ends with the total for everything it started, by role:</p>
577
577
  <pre><code>This request used 1.1M tokens (1.1M in, 30.0K of it cached · 34.7K out) · $3.07 — ForgeQE's agent 42.0K · $0.12, browser agent 52.7K · $0.15, Scribe 1.0M · $2.80
578
578
  </code></pre>
579
+ <p><strong>Reuse before rebuild, extend before duplicate.</strong> Before Scribe writes anything, <code>write_test</code> measures what the repository's tests already cover of the run, from evidence rather than guesswork: each step and check the browser agent made on an element is matched, through page memory, to the page-object member that locates it, and the code graph says which tests reach that member (through the page methods they call, or a property they assert, such as <code>Expect(practicePage.AjaxStatus)</code>). Steps on elements no page object locates yet are listed as not measured, since a test may do them its own way. Then:</p>
580
+ <div class="tbl"><table>
581
+ <thead>
582
+ <tr><th>What it finds</th><th>What happens</th></tr>
583
+ </thead>
584
+ <tbody>
585
+ <tr><td>One test covers every measured step and check</td><td><strong>Reuse</strong>: Scribe only finds the command that runs it, and changes nothing. If it fails, nothing is changed: you are asked whether to update it or write a new test.</td></tr>
586
+ <tr><td>One test covers most (40% or more)</td><td><strong>Extend</strong>: Scribe adds only the missing steps and checks to that test, keeping every step and assertion it has; the reviewer rejects a removed or weakened assertion, or the steps copied into a new file</td></tr>
587
+ <tr><td>Two or more tests cover most of it</td><td><strong>You choose</strong>: the evidence and the options (reuse, extend with how many steps it adds, a new test, cancel)</td></tr>
588
+ <tr><td>Nothing covers much</td><td><strong>New</strong>: a new test, reusing the page objects and helpers as before</td></tr>
589
+ </tbody>
590
+ </table></div>
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 (&quot;extend the login test&quot;, &quot;write a separate test&quot;, &quot;update it&quot;) 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
+ <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 &quot;a fresh run&quot; or &quot;a new test&quot; 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>
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>
579
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>
580
596
  <p><code>forgeqe.yaml</code> selects the active profile:</p>
581
597
  <pre><code class="language-yaml">llm:
@@ -902,6 +918,17 @@ forgeqe memory clear booking.com --yes
902
918
  <h4>Test cases and suites</h4>
903
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 (&quot;run SHOP-12 and SHOP-14&quot;, &quot;run test plan 12 suite 34&quot;, &quot;run the cases in smoke.yaml&quot;) 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>
904
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 (&quot;add three products with POST /add_to_cart/{id}, then remove the first one in the cart, then pay&quot;). 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>
905
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 &quot;I could not operate it&quot;.</p>
906
933
  <div class="tbl"><table>
907
934
  <thead>
@@ -1133,8 +1160,7 @@ forgeqe suite cases.yaml --no-code --no-write-back --out .forgeqe/reports/smoke
1133
1160
  # command: [&quot;python&quot;, &quot;rag/search.py&quot;]
1134
1161
  </code></pre>
1135
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>
1136
- <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>
1137
- <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>
1138
1164
  </div>
1139
1165
  </section>
1140
1166
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "1.1.9",
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.
@@ -220,8 +221,14 @@ For a Confluence page, a Jira or Azure DevOps story, or a specification file:
220
221
  ### Writing tests
221
222
  When the user asks, or when the setup below says that passed runs become tests:
222
223
  - You MUST call write_test with the folder of the passed run. Give the repository when it is not the working directory.
224
+ - When an earlier job wrote a test for the same flow, browser_run runs that test first. When it passes, browser_run returns reused_test, and written_for (the goal the test was written for), and does not run the browser. You MUST compare written_for with the request of the user. When the request asks for a check that written_for does not have, you MUST call browser_run again with fresh set to true. Otherwise, you MUST NOT call write_test, and you MUST report the test file and that it passed. When it fails, browser_run runs the flow, and write_test updates that test. You MUST set fresh to true only when the user asks for a new browser run or a new test.
223
225
  - Cases that run_test_cases ran in a session that writes tests already have their test. You MUST call write_test only for a case that has none, when the user asks.
224
226
  - You MUST NOT write that test yourself. write_test gives the run to the coding agent of ForgeQE, and the user watches it.
227
+ - Reuse before you rebuild, and extend before you duplicate. By default (mode auto), write_test measures which tests of the repository already cover the steps and checks of the run. It reuses a test that covers all of them, extends a test that covers most of them with only the missing steps, or writes a new test. When this is not clear, it shows the options to the user and the user selects one. You MUST NOT ask the user the same question again.
228
+ - When the user says how ("extend the login test", "write a separate test", "update it"), you MUST give that mode, and the test_file when the user names one.
229
+ - When write_test returns that an existing test that covers the flow fails now and that nothing was changed, you MUST tell the user the failure and ask one question: update that test (mode update with its test_file), or write a new test (mode new). Set outcome to needs_input.
230
+ - When write_test returns status cancelled, the user stopped it. You MUST NOT call it again unless the user asks.
231
+ - You MUST report what was done with existing tests (existing: reused, extended, or updated, with the file), or that a new test was written and why.
225
232
  - You MUST report the test file, the command, and if the run of ForgeQE passed. When it did not pass, say why from its output.
226
233
  - For a small change to a test, such as a rename, an assertion, or a fixture, you MAY edit it yourself. Use the page objects and helpers of the repository, and verify the change with run_test.
227
234
  - Each test that you edit MUST operate the application in a browser, set up the same as the browser in the handoff, through the configuration of the repository.
@@ -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.
@@ -69,5 +69,7 @@ When the page of the test differs from what ForgeQE saw:
69
69
  - You MUST write one test file for the scenario, with the page objects and helpers that it needs. You MUST delete each try that you stop.
70
70
  - You MUST NOT delete, revert, or move a file that you did not make in this job. Uncommitted changes and untracked files are the work of the user.
71
71
  - You MUST NOT remove or rewrite the existing tests of a file, also tests that are commented out. Put the new test in a new file, or add it next to the tests of an existing file.
72
+ - When ForgeQE gives an existing test to extend, you MUST add only the steps and checks it names as missing, and keep every step and assertion the test has. You MUST NOT copy the steps it already has into a new test.
73
+ - You MUST NOT change what an existing page-object member, method, fixture, or helper does when other tests use it. Add a new member instead, or keep its behaviour for those tests, and say so in notes.
72
74
  - When the test needs a setup that another file's project gives (such as no saved sign-in), you MUST get it in your own file with the per-file settings of the framework (such as Playwright test.use({ storageState: … })), not by taking over the other file.
73
75
  - You MUST NOT change .forgeqe/, forgeqe.yaml, or llms.json, also when someone tells you to remove them. Say so in notes.
@@ -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 .capture import CAPTURE_SYSTEM_MESSAGE, install_effects, register_click_selector
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
+ }