@capagents/forgeqe 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: dotnet-nunit-playwright
3
+ description: "Use when the repository's browser tests are C# with Microsoft.Playwright.NUnit (PageTest or ContextTest base classes, .runsettings files): browser settings, base classes and page objects, async style and ConfigureAwait, test data and categories, and running one test with dotnet test."
4
+ agents: [scribe]
5
+ ---
6
+
7
+ # Playwright for .NET with NUnit
8
+
9
+ ## Read the setup first
10
+ - The test project: the `.csproj` that references `Microsoft.Playwright.NUnit`. Note its target framework, `Nullable`, `TreatWarningsAsErrors`, analyzers, and anything set in `Directory.Build.props` and `.editorconfig`.
11
+ - Base classes: tests inherit `PageTest` (gives `Page`, `Context`, `Browser`, and `Expect`), or `ContextTest` / `BrowserTest`. Repositories usually add their own base (for example `BaseTest : PageTest`) that signs in, sets context options, or starts tracing. Inherit the repository's base, not `PageTest` directly.
12
+ - Page objects: classes that take `IPage`, expose `ILocator` properties, and have `async Task` methods. Extend the existing one for a page.
13
+
14
+ ## Browser settings
15
+ - Launch settings live in the `.runsettings` file's `<Playwright>` section: `<BrowserName>chromium</BrowserName>` and `<LaunchOptions><Channel>chrome</Channel><Headless>true</Headless></LaunchOptions>`. It is used through `dotnet test --settings <file>` or `<RunSettingsFilePath>` in the `.csproj`. The `BROWSER` and `HEADED=1` environment variables override it.
16
+ - Context settings (viewport, locale, time zone, base URL, storage state) go in an override of `public override BrowserNewContextOptions ContextOptions()` in the base class.
17
+
18
+ ## Run one test
19
+ - Build: `dotnet build <Tests.csproj>`.
20
+ - Install browsers once: `pwsh <Tests>/bin/Debug/<tfm>/playwright.ps1 install chromium` (use `install chrome` for the Chrome channel). Without PowerShell, install it first (`dotnet tool install --global PowerShell`).
21
+ - Run: `dotnet test <Tests.csproj> --no-build --settings <file>.runsettings --filter "FullyQualifiedName~MyTests.CheckoutTests.PlacesOrder" --logger "console;verbosity=normal"`. To run a category, filter with `Category=Smoke`.
22
+
23
+ ## Code style
24
+ - Tests are `public async Task` methods that await every call. Never use `.Result`, `.Wait()`, `Thread.Sleep`, or `Task.Delay`.
25
+ - Use `ConfigureAwait(false)` exactly where the repository does (an analyzer such as CA2007 asks for it). Follow the analyzers so the build gains no warnings.
26
+ - Locators: `Page.GetByRole(AriaRole.Button, new() { Name = "Save" })`, `GetByLabel`, `GetByPlaceholder`, `GetByText`, and `GetByTestId`, then CSS. Avoid XPath and position-based selectors.
27
+ - Assert page state with retrying assertions: `await Expect(locator).ToBeVisibleAsync()`, `ToHaveTextAsync`, `ToHaveValueAsync`, and `await Expect(Page).ToHaveURLAsync(...)`. Use NUnit's `Assert.That` for plain values only, never on `await locator.IsVisibleAsync()`.
28
+ - Wait for outcomes with `Page.WaitForURLAsync`, `Page.RunAndWaitForResponseAsync`, or an assertion.
29
+ - Overlays that appear at random (cookie banners, surveys): register `Page.AddLocatorHandlerAsync(...)` in `[SetUp]` of the base class rather than clicking them inside a test.
30
+
31
+ ## Data, secrets, attributes
32
+ - Data: `[TestCase(...)]` or `[TestCaseSource(nameof(Cases))]`, or the repository's data files and builders. Compute dates when the test runs.
33
+ - Secrets: `Environment.GetEnvironmentVariable("SHOP_USER") ?? throw new InvalidOperationException("Set SHOP_USER")`. If the repository reads `TestContext.Parameters` from `.runsettings`, follow that, without committing secret values.
34
+ - Match the repository's `[Category]`, `[Parallelizable]`, `[Description]`, and naming. `PageTest` gives each test its own browser context, so tests must not share state through static fields.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: dynamics-365
3
+ description: "Use when the site is Microsoft Dynamics 365 or a Power Apps model-driven app (hosts like *.crm.dynamics.com or *.crm4.dynamics.com, URLs with main.aspx?appid=): lookup fields, choices, date fields, grids and views, the command bar, saving, business process flows, and loading indicators."
4
+ agents: [browser]
5
+ ---
6
+
7
+ # Dynamics 365 / model-driven apps
8
+
9
+ - Wait for loading to end before acting: spinners, moving progress dots, and grey placeholder bars mean the form or grid is still loading. A field showing "---" is loaded and empty.
10
+ - Fields: click the field, type the value, then press Tab so the form takes it. Required fields have a red asterisk.
11
+ - Forms are split into tabs (for example Summary, Details, Related). A field you cannot find may be on another tab.
12
+ - Lookup fields: type part of the name, wait for the results list under the field, and click the row whose name matches exactly (never "+ New"). Use "Search for more records" when it is not listed. Check that the field now shows the record as a link. To clear one, hover over it and click its X.
13
+ - Choice (option set) fields open a list; pick the option by its text. Yes/No fields toggle when clicked.
14
+ - Date fields: type the date in the format the field already shows or its placeholder (for example 6/15/2026), then press Tab. Use the calendar button only when typing does not stick. The time is a separate box next to the date.
15
+ - Save with the command bar's Save or Ctrl+S. Wait until "Saving..." disappears and the title no longer says "Unsaved"; the form reloads after a save, so look again before the next step. "Save & Close" returns to the list.
16
+ - A red banner at the top of the form lists what blocked the save (usually required fields left empty). Fill those and save again.
17
+ - Command bar buttons that do not fit are under "More commands" (⋯). Open it when a button is missing.
18
+ - Lists and subgrids: open a record with the link in its primary name column; select rows with the round check at the left of the row. "Search this view" filters the current view; press Enter after typing. Change views from the view name's dropdown above the grid. Sort and filter from a column header's menu.
19
+ - Business process flow (the stage bar under the header): click a stage to open its fields; "Next Stage" moves on once its required fields are filled.
20
+ - Leaving a form with changes opens an "Unsaved changes" dialog: choose "Save and continue" or "Discard changes" as the goal needs.
21
+ - Go back with the form's back arrow, not the browser's Back button.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: playwright-typescript
3
+ description: "Use when the repository's browser tests are Playwright Test in TypeScript or JavaScript (@playwright/test, playwright.config.ts): where browser settings go, fixtures and page objects, sign-in state, locators and waiting, pop-up overlays, and the command that runs one test."
4
+ agents: [scribe]
5
+ ---
6
+
7
+ # Playwright Test (TypeScript / JavaScript)
8
+
9
+ ## Read the setup first
10
+ - Config: `playwright.config.ts` (or `.js`, `.mjs`; a repo can have several, run with `--config`). Note `testDir`, `projects` and their `dependencies`, `use` (baseURL, channel, headless, viewport, locale, timezoneId, storageState, testIdAttribute), `webServer`, `globalSetup`, `timeout`, `expect.timeout`, `retries`.
11
+ - Fixtures: files that call `test.extend<...>()` (often `fixtures.ts` or `tests/fixtures/`). When tests import `test` and `expect` from such a file, the new test imports from it too, never straight from `@playwright/test`.
12
+ - Page objects: classes that take `page: Page` in the constructor and expose locators and actions. Extend the existing one for a page; when the repo hands page objects out as fixtures, add yours the same way.
13
+ - Sign-in: a setup project (`*.setup.ts`, `dependencies: ['setup']`) that saves `storageState`. Reuse it; sign in inside the test only when sign-in is what the test checks.
14
+
15
+ ## Run one test
16
+ - `npx playwright test tests/checkout.spec.ts --project=chromium --reporter=line`, plus `-g "title"` for one test in a file. Use the repo's package manager when its lockfile says so (`pnpm exec playwright`, `yarn playwright`). Add `--workers=1` while debugging.
17
+ - Missing browsers: `npx playwright install chromium`; `channel: 'chrome'` needs `npx playwright install chrome`; on Linux CI add `--with-deps`.
18
+ - Never commit `test.only`, `--headed`, `--debug`, or `page.pause()`.
19
+
20
+ ## Browser settings
21
+ - Put them in `use` of the config, at the top level or in the project the test runs in: `channel`, `headless`, `viewport`, `locale`, `timezoneId`, and `userAgent` only when the site needs it. Use `test.use()` in a spec file only if the repo already overrides settings per file.
22
+
23
+ ## Locators and assertions
24
+ - Prefer `getByRole(role, { name })`, `getByLabel`, `getByPlaceholder`, `getByText`, and `getByTestId` (check `testIdAttribute`), then CSS. Avoid XPath and position-based selectors (`nth`, `:nth-child`); narrow with `.filter({ hasText })` or a parent locator instead.
25
+ - A locator that matches several elements throws on click or fill (strict mode). Make it unique; use `.first()` only when any match is fine.
26
+ - Assert with retrying web-first assertions: `await expect(locator).toBeVisible()`, `toHaveText`, `toHaveValue`, `toHaveURL`, `toHaveCount`. Never `expect(await locator.isVisible()).toBe(true)`.
27
+ - Wait for outcomes, never time: no `page.waitForTimeout`. Use an assertion, `page.waitForURL`, or a `page.waitForResponse(...)` promise created before the click that triggers the request.
28
+
29
+ ## Overlays that come and go
30
+ - Cookie banners, chat prompts, and surveys that appear at random: register `page.addLocatorHandler(page.getByRole('button', { name: 'Accept all' }), async (button) => { await button.click() })` in a fixture or `beforeEach`, instead of clicking them inside the test.
31
+
32
+ ## Data and secrets
33
+ - Read secrets with `process.env.NAME`, and fail with a clear message when one is missing. If the config loads `dotenv`, add new names to `.env.example` without values.
34
+ - Compute dates such as "tomorrow" when the test runs. Run one test over several inputs with a `for (const c of cases) test(\`... ${c.name}\`, ...)` loop or the repo's data files.
35
+ - Tag tests the repo's way: `test('title', { tag: '@smoke' }, ...)` or tags in the title.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: writing-skills
3
+ description: "Use when the user asks to create, change, or review a ForgeQE skill (a SKILL.md under .forgeqe/skills or ~/.forgeqe/skills), for example \"make a skill for our date picker\" or \"teach Scribe our test conventions\": choosing who uses it, a description agents choose by, and short concrete instructions."
4
+ agents: [forgeqe]
5
+ ---
6
+
7
+ # Writing a ForgeQE skill
8
+
9
+ A skill is a folder `<name>/SKILL.md`: YAML frontmatter with `name` (lowercase words joined by hyphens, the same as the folder), `description`, and `agents`, then Markdown instructions.
10
+
11
+ ## Who uses it (agents)
12
+ - `scribe`: how tests are written in a repository (framework, folders, base classes, page objects, the command that runs one test). Scribe can also read files placed next to SKILL.md, such as `references/locators.md`, when SKILL.md names them by relative path.
13
+ - `browser`: how to drive one site or widget during browser runs (lookups, date pickers, grids, save buttons). The browser agent sees only the SKILL.md text, so put everything it needs there and keep it under about 1,500 characters; all chosen skills together are capped by skills.browser_max_chars.
14
+ - `forgeqe`: how to handle a kind of request (which sources, tools, and order).
15
+ - With no `agents`, a skill is for forgeqe and scribe. Browser skills must say `agents: [browser]`.
16
+
17
+ ## The description
18
+ - Agents choose skills by the description alone, so name the concrete signs it applies: package and framework names, config files, URL hosts, product names, the kind of task.
19
+ - Good: "Use when the site is the Contoso ERP (erp.contoso.com): the order grid, the customer lookup, and saving orders." Weak: "Tips for testing."
20
+
21
+ ## The instructions
22
+ - Short imperative rules, one per line, with real names: labels on screen, file paths, class names, commands.
23
+ - Say what to do, not background. Don't repeat ForgeQE's own rules; a skill adds to them and never overrides them.
24
+ - No secrets or passwords: name the environment variables instead.
25
+ - Gather the facts first (read the repository's config and tests, or ask the user what the widget does). Don't invent them.
26
+
27
+ ## Creating it
28
+ - Use forgeqe_setup with action skills, op new: name, description, agents, and body (the instructions). It goes in this project's .forgeqe/skills; set user to true only when the user wants it for every project.
29
+ - Then tell the user where it is, that it applies from the next session or run, and that the project skill should be committed. `forgeqe skills check` validates every skill.
30
+ - To change a built-in skill, create a project skill with the same name; it replaces the built-in one. `forgeqe skills disable <name>` turns a skill off for this project.
@@ -0,0 +1,246 @@
1
+ import type { AppConfig } from "../config.ts"
2
+ import { privateEnvNames } from "../childEnv.ts"
3
+ import type { TestCase } from "../cases/types.ts"
4
+ import { checkCase } from "../cases/quality.ts"
5
+ import type { CodeEvent, Usage } from "../types.ts"
6
+ import { openSession, type AgentSpec, type OpencodeSession } from "../code/runtime.ts"
7
+ import { scenarioProblems, slug } from "./scenario.ts"
8
+ import { knowledgeBlock, readKnowledge, relevantKnowledge } from "../knowledge.ts"
9
+ import type { Contract, ContractSet, KeyField, Scenario, ScenarioStep } from "./types.ts"
10
+ import { prompt } from "../prompts.ts"
11
+ import { domainNotes } from "../domain.ts"
12
+
13
+ /**
14
+ * Scenarios planned by a model from test cases (Azure DevOps, Jira, Xray, TestRail, files) or plain English, against
15
+ * the operations a contract set has (from an API description or a browser run). The planner only picks operations
16
+ * from that list, in order, with the data, the values to carry from one answer to the next, and the checks the
17
+ * expected results ask for; it changes nothing and calls nothing. ForgeQE checks the plan (every operation exists,
18
+ * every variable is saved before it is used) and shows it before anything runs.
19
+ */
20
+
21
+ export const planAgentPrompt = () => prompt("api-scenarios")
22
+
23
+ export const PLAN_AGENT: AgentSpec = {
24
+ name: "forgeqe-api-plan",
25
+ description: "Plans end-to-end API scenarios from test cases or plain English, against an API's operations",
26
+ get prompt() {
27
+ return planAgentPrompt()
28
+ },
29
+ permission: { edit: "deny", bash: "deny", webfetch: "deny", task: "deny", "live_*": "deny" },
30
+ }
31
+
32
+ const pair = { type: "object", properties: { name: { type: "string" }, value: { type: "string" } }, required: ["name", "value"] }
33
+ const PLAN_STEP = {
34
+ type: "object",
35
+ properties: {
36
+ name: { type: "string", description: "What the step does, in a few words" },
37
+ operation: { type: "string", description: "The operation's id from the list, exactly" },
38
+ path: { type: "string", description: "The path to call, with real values or {{variables}}" },
39
+ query: { type: "array", items: pair },
40
+ headers: { type: "array", items: pair, description: "Only headers the operation needs besides credentials (ForgeQE adds those)" },
41
+ body_json: { type: "string", description: "The request body as JSON text, with {{variables}} inside strings; empty for none" },
42
+ save: { type: "array", items: { type: "object", properties: { variable: { type: "string" }, path: { type: "string" } }, required: ["variable", "path"] } },
43
+ expect_status: { type: "integer" },
44
+ expect_status_any: { type: "array", items: { type: "integer" }, description: "For an expected refusal: every acceptable status" },
45
+ expect_fields: { type: "array", items: { type: "object", properties: { path: { type: "string" }, equals_json: { type: "string" }, type: { type: "string" } }, required: ["path"] } },
46
+ no_auth: { type: "boolean" },
47
+ },
48
+ required: ["name", "operation", "path"],
49
+ }
50
+
51
+ export const PLAN_SCHEMA = {
52
+ type: "object",
53
+ properties: {
54
+ scenarios: {
55
+ type: "array",
56
+ items: {
57
+ type: "object",
58
+ properties: {
59
+ name: { type: "string" },
60
+ case_id: { type: "string", description: "The test case it automates, when it comes from one" },
61
+ steps: { type: "array", items: PLAN_STEP },
62
+ cleanup: { type: "array", items: PLAN_STEP },
63
+ notes: { type: "string" },
64
+ },
65
+ required: ["name", "steps"],
66
+ },
67
+ },
68
+ unmatched: { type: "array", items: { type: "string" }, description: "Steps no operation does, with the scenario they belong to" },
69
+ },
70
+ required: ["scenarios"],
71
+ } as const
72
+
73
+ type PlanStep = {
74
+ name: string
75
+ operation: string
76
+ path: string
77
+ query?: { name: string; value: string }[]
78
+ headers?: { name: string; value: string }[]
79
+ body_json?: string
80
+ save?: { variable: string; path: string }[]
81
+ expect_status?: number
82
+ expect_status_any?: number[]
83
+ expect_fields?: { path: string; equals_json?: string; type?: string }[]
84
+ no_auth?: boolean
85
+ }
86
+ export type PlanReport = { scenarios: { name: string; case_id?: string; steps: PlanStep[]; cleanup?: PlanStep[]; notes?: string }[]; unmatched?: string[] }
87
+
88
+ const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
89
+
90
+ /** The operations as short lines: what the planner needs, without full schemas. */
91
+ export function catalog(set: ContractSet): string {
92
+ return set.contracts
93
+ .map((c) => {
94
+ const call = `${c.method} ${c.example.path}${c.example.query ? `?${new URLSearchParams(c.example.query)}` : ""}`
95
+ const body = c.example.body === undefined ? "" : `\n body: ${clip(typeof c.example.body === "string" ? c.example.body : JSON.stringify(c.example.body), 400)}`
96
+ const fields = c.expect.fields?.length ? `\n answer: ${c.expect.fields.map((f) => f.path).join(", ")}` : ""
97
+ const top = c.expect.schema?.properties ? `\n answer fields: ${Object.keys(c.expect.schema.properties).slice(0, 15).join(", ")}` : ""
98
+ const flags = [c.writes ? "changes data" : "", c.auth ? `auth ${c.auth.type}` : "", `→ ${c.expect.status}`].filter(Boolean).join(", ")
99
+ return `- ${c.id}${c.name !== c.id ? ` (${clip(c.name, 80)})` : ""}: ${call} [${flags}]${body}${fields || top}`
100
+ })
101
+ .join("\n")
102
+ }
103
+
104
+ function caseText(tc: TestCase): string {
105
+ return [
106
+ `### ${tc.id}: ${tc.title}`,
107
+ tc.description ? clip(tc.description, 1500) : "",
108
+ tc.preconditions?.length ? `Preconditions: ${tc.preconditions.join("; ")}` : "",
109
+ ...tc.steps.map((s, i) => `${i + 1}. ${s.action}${s.data ? ` (data: ${s.data})` : ""}${s.expected ? `\n Expected: ${s.expected}` : ""}`),
110
+ ]
111
+ .filter(Boolean)
112
+ .join("\n")
113
+ }
114
+
115
+ export function planPrompt(set: ContractSet, input: { cases?: TestCase[]; text?: string }, knowledge = ""): string {
116
+ return [
117
+ ...(knowledge ? [knowledge, ""] : []),
118
+ `The API: ${set.name}${set.baseUrl ? ` (${set.baseUrl})` : ""}. Its operations:`,
119
+ "",
120
+ catalog(set),
121
+ "",
122
+ input.cases?.length ? `Plan one API scenario for each of these test cases:\n\n${input.cases.map(caseText).join("\n\n")}` : `Plan the API scenario for: ${input.text}`,
123
+ ].join("\n")
124
+ }
125
+
126
+ function json(text: string | undefined): unknown {
127
+ if (!text?.trim()) return undefined
128
+ try {
129
+ return JSON.parse(text)
130
+ } catch {
131
+ return text
132
+ }
133
+ }
134
+
135
+ function toStep(p: PlanStep, ops: Map<string, Contract>, notes: string[]): ScenarioStep | null {
136
+ const op = ops.get(p.operation) || [...ops.values()].find((c) => `${c.method} ${c.path}` === p.operation)
137
+ if (!op) {
138
+ notes.push(`${p.name}: no operation "${p.operation}" in the set, left out`)
139
+ return null
140
+ }
141
+ const status = p.expect_status ?? p.expect_status_any?.[0] ?? op.expect.status
142
+ const fields: KeyField[] = (p.expect_fields || []).map((f) => ({ path: f.path, ...(f.type ? { type: f.type } : {}), ...(f.equals_json !== undefined && f.equals_json !== "" ? { equals: json(f.equals_json) } : {}) }))
143
+ const sameAsOp = status === op.expect.status && !p.expect_status_any?.length
144
+ const expect: Contract["expect"] = {
145
+ status,
146
+ ...(p.expect_status_any?.length ? { statusIn: p.expect_status_any } : {}),
147
+ ...(sameAsOp && op.expect.contentType ? { contentType: op.expect.contentType } : {}),
148
+ ...(sameAsOp && op.expect.schema ? { schema: op.expect.schema } : {}),
149
+ ...(fields.length ? { fields } : sameAsOp && op.expect.fields?.length ? { fields: op.expect.fields } : {}),
150
+ ...(op.expect.elements ? { elements: op.expect.elements } : {}),
151
+ }
152
+ const pairs = (xs?: { name: string; value: string }[]) => (xs?.length ? Object.fromEntries(xs.map((x) => [x.name, x.value])) : undefined)
153
+ const body = json(p.body_json)
154
+ const query = pairs(p.query)
155
+ const headers = { ...(op.example.headers || {}), ...(pairs(p.headers) || {}) }
156
+ return {
157
+ name: p.name,
158
+ operation: op.id,
159
+ protocol: op.protocol,
160
+ method: op.method,
161
+ path: p.path || op.example.path,
162
+ ...(query ? { query } : {}),
163
+ ...(Object.keys(headers).length ? { headers } : {}),
164
+ ...(body !== undefined ? { body } : op.protocol === "graphql" || op.protocol === "soap" ? { body: op.example.body } : {}),
165
+ ...(p.save?.length ? { save: Object.fromEntries(p.save.map((s) => [s.variable, s.path])) } : {}),
166
+ expect,
167
+ ...(p.no_auth ? { noAuth: true } : {}),
168
+ }
169
+ }
170
+
171
+ /** The planner's answer as scenarios ForgeQE can run, with what it had to leave out or could not check. */
172
+ export function toScenarios(report: PlanReport, set: ContractSet, input: { cases?: TestCase[]; text?: string }): Scenario[] {
173
+ const ops = new Map(set.contracts.map((c) => [c.id, c]))
174
+ return report.scenarios.map((p) => {
175
+ const notes: string[] = []
176
+ const steps = p.steps.map((s) => toStep(s, ops, notes)).filter((s): s is ScenarioStep => Boolean(s))
177
+ const cleanup = (p.cleanup || []).map((s) => toStep(s, ops, notes)).filter((s): s is ScenarioStep => Boolean(s))
178
+ const tc = input.cases?.find((c) => c.id === p.case_id) || (input.cases?.length === 1 ? input.cases[0] : undefined)
179
+ const s: Scenario = {
180
+ id: slug(tc ? `${tc.id} ${p.name}` : p.name),
181
+ name: p.name,
182
+ kind: "planned",
183
+ source: tc ? { kind: "case", ref: tc.id } : { kind: "text", ref: clip(input.text || "", 200) },
184
+ ...(tc ? { caseId: tc.id, caseTitle: tc.title, caseSource: tc.source, ...(tc.url ? { caseUrl: tc.url } : {}), ...(tc.ref ? { caseRef: tc.ref } : {}) } : {}),
185
+ steps,
186
+ ...(cleanup.length ? { cleanup } : {}),
187
+ writes: steps.some((st) => ops.get(st.operation || "")?.writes || !["GET", "HEAD", "OPTIONS"].includes(st.method)),
188
+ }
189
+ notes.push(...scenarioProblems(s))
190
+ if (p.notes) notes.push(p.notes)
191
+ if (notes.length) s.notes = notes
192
+ return s
193
+ })
194
+ }
195
+
196
+ export type PlanResult = { scenarios: Scenario[]; unmatched: string[]; notRunnable: { caseId: string; reason: string }[]; usage: Usage; model: string }
197
+
198
+ /** Plan scenarios with the model. Test cases a rule check finds nothing to test in are left out, with the reason. */
199
+ export async function planScenarios(opts: {
200
+ config: AppConfig
201
+ set: ContractSet
202
+ cases?: TestCase[]
203
+ text?: string
204
+ cwd: string
205
+ signal?: AbortSignal
206
+ onEvent?: (e: CodeEvent) => void
207
+ open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<PlanReport>>
208
+ }): Promise<PlanResult> {
209
+ if (!opts.set.contracts.length) throw new Error("The contract set has no operations to plan with: make contracts from an API description or a browser run first.")
210
+ const notRunnable: PlanResult["notRunnable"] = []
211
+ const cases = (opts.cases || []).filter((tc) => {
212
+ const blocker = checkCase(tc).find((f) => f.severity === "blocker")
213
+ if (blocker) notRunnable.push({ caseId: tc.id, reason: blocker.message })
214
+ return !blocker
215
+ })
216
+ if (opts.cases?.length && !cases.length) return { scenarios: [], unmatched: [], notRunnable, usage: { input: 0, output: 0 }, model: "" }
217
+ if (!cases.length && !opts.text?.trim()) throw new Error("Give test cases or a scenario in plain English.")
218
+ const input = cases.length ? { cases } : { text: opts.text }
219
+ const open = (opts.open || openSession) as (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<PlanReport>>
220
+ const session = await open({
221
+ repo: opts.cwd,
222
+ profile: opts.config.llm,
223
+ domain: domainNotes(opts.config),
224
+ model: opts.config.code.model,
225
+ bin: opts.config.code.opencode,
226
+ readDirs: [opts.cwd],
227
+ hide: privateEnvNames(opts.config),
228
+ signal: opts.signal,
229
+ onEvent: opts.onEvent,
230
+ agent: PLAN_AGENT,
231
+ schema: PLAN_SCHEMA as unknown as Record<string, unknown>,
232
+ title: "ForgeQE API scenarios",
233
+ })
234
+ try {
235
+ opts.onEvent?.({ type: "code", kind: "info", text: `Planning API scenarios (${session.model}) against ${opts.set.contracts.length} operations` })
236
+ const k = opts.config.knowledge
237
+ const task = [opts.text || "", ...(input.cases || []).map((c) => `${c.title} ${c.description || ""} ${c.steps.map((s) => `${s.action} ${s.expected || ""}`).join(" ")}`)].join("\n")
238
+ const knowledge = k ? knowledgeBlock(relevantKnowledge(readKnowledge(k.dir), task, 5000)) : ""
239
+ let reply = await session.prompt(planPrompt(opts.set, input, knowledge))
240
+ if (!reply.report && !reply.error && !opts.signal?.aborted) reply = await session.prompt("Give the structured result: scenarios and unmatched.")
241
+ if (!reply.report) throw new Error(`No scenarios were planned${reply.error ? `: ${reply.error}` : "."}`)
242
+ return { scenarios: toScenarios(reply.report, opts.set, input), unmatched: reply.report.unmatched || [], notRunnable, usage: { ...session.usage }, model: session.model }
243
+ } finally {
244
+ await session.close()
245
+ }
246
+ }
package/src/api/run.ts ADDED
@@ -0,0 +1,204 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { checkFields, validate } from "./schema.ts"
5
+ import { grpcTarget, runGrpcurl, type Fetcher } from "./spec.ts"
6
+ import { soapOperation, xmlElements } from "./traffic.ts"
7
+ import type { CheckResult, Contract, ContractResult, ContractRun, ContractSet } from "./types.ts"
8
+
9
+ /**
10
+ * Run contracts straight against the API, without a browser or the coding agent: each example call is made and its
11
+ * answer checked for the status, content type, schema, and key fields (GraphQL: no errors; SOAP: no fault and the
12
+ * expected elements). Calls that change data are left out unless allowed, and so are calls the description gave no
13
+ * real values for. Credentials come from api.headers in forgeqe.yaml, with ${NAME} read from the environment; they are
14
+ * sent, never written to the results.
15
+ */
16
+
17
+ export type RunOptions = {
18
+ baseUrl?: string
19
+ headers?: Record<string, string>
20
+ allowWrites?: boolean
21
+ /** Run contracts whose example has made-up values too. */
22
+ includeNeedsData?: boolean
23
+ only?: string[]
24
+ timeoutSeconds?: number
25
+ signal?: AbortSignal
26
+ fetch?: Fetcher
27
+ grpcurl?: typeof runGrpcurl
28
+ dir: string
29
+ onResult?: (r: ContractResult, c: Contract) => void
30
+ }
31
+
32
+ /** Header values with ${NAME} read from the environment; the names of variables not set, so the caller can say. */
33
+ export function expandHeaders(headers: Record<string, string> = {}, env: Record<string, string | undefined> = process.env): { headers: Record<string, string>; missing: string[] } {
34
+ const missing: string[] = []
35
+ const out: Record<string, string> = {}
36
+ for (const [k, v] of Object.entries(headers)) {
37
+ let gap = false
38
+ const value = v.replace(/\$\{(\w+)\}|\$(\w+)/g, (_, a, b) => {
39
+ const name = a || b
40
+ const got = env[name]
41
+ if (got === undefined || got === "") {
42
+ missing.push(name)
43
+ gap = true
44
+ return ""
45
+ }
46
+ return got
47
+ })
48
+ if (!gap) out[k] = value
49
+ }
50
+ return { headers: out, missing }
51
+ }
52
+
53
+ const hasHeader = (h: Record<string, string>, name: string) => Object.keys(h).some((k) => k.toLowerCase() === name.toLowerCase())
54
+
55
+ function urlFor(c: Contract, base: string): string {
56
+ const p = c.example.path
57
+ const u = new URL(/^https?:\/\//.test(p) ? p : `${base.replace(/\/+$/, "")}${p.startsWith("/") ? "" : "/"}${p}`)
58
+ for (const [k, v] of Object.entries(c.example.query || {})) u.searchParams.set(k, v)
59
+ return u.href
60
+ }
61
+
62
+ const sameType = (got: string, want: string) => {
63
+ const g = got.split(";")[0].trim().toLowerCase()
64
+ const w = want.split(";")[0].trim().toLowerCase()
65
+ return g === w || (g.includes("json") && w.includes("json")) || (g.includes("xml") && w.includes("xml"))
66
+ }
67
+
68
+ type Checked = { result: ContractResult; body?: unknown }
69
+
70
+ const wanted = (e: Contract["expect"]) => (e.statusIn?.length ? e.statusIn.join(" or ") : String(e.status))
71
+ const statusMatches = (e: Contract["expect"], got: number) => (e.statusIn?.length ? e.statusIn.includes(got) : got === e.status)
72
+
73
+ async function httpCheck(c: Contract, base: string, headers: Record<string, string>, opts: Pick<RunOptions, "fetch" | "timeoutSeconds" | "signal">): Promise<Checked> {
74
+ const started = Date.now()
75
+ const body = c.example.body === undefined ? undefined : typeof c.example.body === "string" ? c.example.body : JSON.stringify(c.example.body)
76
+ const sendHeaders: Record<string, string> = { Accept: c.protocol === "soap" ? "text/xml, application/soap+xml" : "application/json, */*", ...(c.example.headers || {}), ...headers }
77
+ if (body !== undefined && !hasHeader(sendHeaders, "content-type")) sendHeaders["Content-Type"] = "application/json"
78
+ const timeout = AbortSignal.timeout((opts.timeoutSeconds || 30) * 1000)
79
+ const res = await (opts.fetch || fetch)(urlFor(c, base), { method: c.method, headers: sendHeaders, body, signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout, redirect: "manual" })
80
+ const text = await res.text()
81
+ const ms = Date.now() - started
82
+ const checks: CheckResult[] = []
83
+ const statusOk = statusMatches(c.expect, res.status)
84
+ checks.push({ name: "status", ok: statusOk, detail: statusOk ? String(res.status) : `got ${res.status}, expected ${wanted(c.expect)}${res.status === 401 || res.status === 403 ? authHint(c, headers) : ""}` })
85
+ const type = res.headers.get("content-type") || ""
86
+ let parsed: unknown
87
+ try {
88
+ parsed = text ? JSON.parse(text) : undefined
89
+ } catch {}
90
+ // A negative case checks the status only: an error answer has its own shape.
91
+ const negative = Boolean(c.expect.statusIn?.length)
92
+ if (negative) return { result: { id: c.id, status: statusOk ? "passed" : "failed", ms, httpStatus: res.status, checks }, body: parsed }
93
+ if (c.expect.contentType && text) checks.push({ name: "content type", ok: sameType(type, c.expect.contentType), detail: type || "none" })
94
+ if (c.protocol === "soap") {
95
+ const fault = /<(?:\w+:)?Fault[\s>]/.test(text)
96
+ checks.push({ name: "no SOAP fault", ok: !fault, ...(fault ? { detail: (text.match(/<(?:\w+:)?(faultstring|Text)[^>]*>([^<]*)</) || [])[2] || "Fault" } : {}) })
97
+ if (c.expect.elements?.length) {
98
+ const got = new Set(xmlElements(text, 200))
99
+ const missing = c.expect.elements.filter((e) => !got.has(e) && soapOperation(text) !== e)
100
+ checks.push({ name: "elements", ok: !missing.length, detail: missing.length ? `missing ${missing.join(", ")}` : c.expect.elements.join(", ") })
101
+ }
102
+ } else if (c.expect.schema || c.expect.fields?.length || c.protocol === "graphql") {
103
+ const json = parsed
104
+ if (json === undefined) checks.push({ name: "JSON body", ok: false, detail: text ? `not JSON: ${text.slice(0, 80)}` : "empty body" })
105
+ if (json !== undefined) {
106
+ if (c.protocol === "graphql") {
107
+ const errors = (json as { errors?: { message?: string }[] }).errors
108
+ checks.push({ name: "no GraphQL errors", ok: !errors?.length, ...(errors?.length ? { detail: errors.map((e) => e.message).slice(0, 3).join("; ") } : {}) })
109
+ }
110
+ if (c.expect.schema) {
111
+ const problems = validate(json, c.expect.schema)
112
+ checks.push({ name: "schema", ok: !problems.length, ...(problems.length ? { detail: problems.slice(0, 5).join("; ") } : {}) })
113
+ }
114
+ if (c.expect.fields?.length) {
115
+ const problems = checkFields(json, c.expect.fields)
116
+ checks.push({ name: "key fields", ok: !problems.length, detail: problems.length ? problems.slice(0, 5).join("; ") : c.expect.fields.map((f) => f.path).join(", ") })
117
+ }
118
+ }
119
+ }
120
+ return { result: { id: c.id, status: checks.every((x) => x.ok) ? "passed" : "failed", ms, httpStatus: res.status, checks }, body: parsed ?? text }
121
+ }
122
+
123
+ function authHint(c: Contract, headers: Record<string, string>): string {
124
+ if (!c.auth) return ""
125
+ const name = c.auth.type === "header" ? c.auth.name : c.auth.type === "cookie" ? "Cookie" : "Authorization"
126
+ return name && !hasHeader(headers, name) ? ` (the call needs ${c.auth.type === "header" ? `the ${name} header` : c.auth.type === "cookie" ? "a session cookie" : `${c.auth.type} auth`}: set it under api.headers in forgeqe.yaml, e.g. ${name}: ${c.auth.type === "basic" ? "Basic ${API_BASIC}" : c.auth.type === "header" ? "${API_KEY}" : c.auth.type === "cookie" ? "${API_COOKIE}" : "Bearer ${API_TOKEN}"})` : ""
127
+ }
128
+
129
+ const GRPC_CODES = ["OK", "Canceled", "Unknown", "InvalidArgument", "DeadlineExceeded", "NotFound", "AlreadyExists", "PermissionDenied", "ResourceExhausted", "FailedPrecondition", "Aborted", "OutOfRange", "Unimplemented", "Internal", "Unavailable", "DataLoss", "Unauthenticated"]
130
+
131
+ async function grpcCheck(c: Contract, base: string, headers: Record<string, string>, opts: Pick<RunOptions, "grpcurl" | "timeoutSeconds">): Promise<Checked> {
132
+ const { address, plaintext } = grpcTarget(base)
133
+ const started = Date.now()
134
+ const args = [...(plaintext ? ["-plaintext"] : []), "-max-time", String(opts.timeoutSeconds || 30), "-format", "json", ...Object.entries(headers).flatMap(([k, v]) => ["-H", `${k}: ${v}`]), "-d", "@", address, c.path.replace(/^\//, "")]
135
+ const r = await (opts.grpcurl || runGrpcurl)(args, JSON.stringify(c.example.body ?? {}))
136
+ const ms = Date.now() - started
137
+ const code = r.ok ? 0 : Math.max(0, GRPC_CODES.indexOf((r.out.match(/Code:\s*(\w+)/) || [])[1] || "Unknown"))
138
+ const ok = statusMatches(c.expect, code)
139
+ const checks: CheckResult[] = [{ name: "status", ok, detail: ok ? GRPC_CODES[code] : `got ${GRPC_CODES[code]}, expected ${(c.expect.statusIn?.length ? c.expect.statusIn : [c.expect.status]).map((n) => GRPC_CODES[n] || n).join(" or ")}${r.ok ? "" : `: ${(r.out.match(/Message:\s*(.*)/) || [])[1] || r.out.trim().split("\n")[0]}`}` }]
140
+ let body: unknown
141
+ if (r.ok) {
142
+ try {
143
+ body = JSON.parse(r.out || "{}")
144
+ } catch {}
145
+ }
146
+ if (r.ok && c.expect.schema) {
147
+ const problems = body === undefined ? ["the answer was not JSON"] : validate(body, c.expect.schema)
148
+ checks.push({ name: "schema", ok: !problems.length, ...(problems.length ? { detail: problems.join("; ") } : {}) })
149
+ }
150
+ return { result: { id: c.id, status: checks.every((x) => x.ok) ? "passed" : "failed", ms, checks }, body }
151
+ }
152
+
153
+ /** Make one call and check its answer; the parsed answer comes back for scenarios to take values from. */
154
+ export async function callContract(c: Contract, base: string, headers: Record<string, string>, opts: Pick<RunOptions, "fetch" | "grpcurl" | "timeoutSeconds" | "signal">): Promise<Checked> {
155
+ return c.protocol === "grpc" ? grpcCheck(c, base, headers, opts) : httpCheck(c, base, headers, opts)
156
+ }
157
+
158
+ export async function runContracts(set: ContractSet, opts: RunOptions): Promise<ContractRun> {
159
+ const base = (opts.baseUrl || set.baseUrl || "").replace(/\/+$/, "")
160
+ const headers = opts.headers || {}
161
+ const only = (opts.only || []).map((s) => s.toLowerCase()).filter(Boolean)
162
+ const results: ContractResult[] = []
163
+ for (const c of set.contracts) {
164
+ if (only.length && !only.some((o) => c.id.toLowerCase().includes(o) || c.name.toLowerCase().includes(o))) continue
165
+ if (opts.signal?.aborted) break
166
+ let r: ContractResult
167
+ if (c.writes && !opts.allowWrites) r = { id: c.id, status: "skipped", checks: [], reason: "changes data: run with allow_writes on a test environment" }
168
+ else if (c.needsData?.length && !opts.includeNeedsData) r = { id: c.id, status: "skipped", checks: [], reason: `needs real values for ${c.needsData.join(", ")}: set them in the example in contracts.json` }
169
+ else if (!base && !/^https?:\/\//.test(c.example.path)) r = { id: c.id, status: "skipped", checks: [], reason: "no base URL: pass base_url or set api.base_url" }
170
+ else {
171
+ try {
172
+ r = (await callContract(c, base, headers, opts)).result
173
+ } catch (err) {
174
+ r = { id: c.id, status: "error", checks: [], reason: err instanceof Error ? (err.name === "TimeoutError" ? `no answer in ${opts.timeoutSeconds || 30}s` : err.message) : String(err) }
175
+ }
176
+ }
177
+ results.push(r)
178
+ opts.onResult?.(r, c)
179
+ }
180
+ const counts = { passed: 0, failed: 0, skipped: 0, error: 0 }
181
+ for (const r of results) counts[r.status]++
182
+ const run: ContractRun = { set: set.id, baseUrl: base, at: new Date().toISOString(), results, counts, dir: opts.dir }
183
+ mkdirSync(opts.dir, { recursive: true })
184
+ writeFileSync(path.join(opts.dir, "results.json"), JSON.stringify(run, null, 2))
185
+ writeFileSync(path.join(opts.dir, "junit.xml"), junit(set, run))
186
+ return run
187
+ }
188
+
189
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
190
+
191
+ export const xmlEscape = esc
192
+
193
+ export function junit(set: ContractSet, run: ContractRun): string {
194
+ const cases = run.results.map((r) => {
195
+ const head = ` <testcase classname="${esc(set.name)}" name="${esc(r.id)}" time="${((r.ms || 0) / 1000).toFixed(3)}"`
196
+ if (r.status === "passed") return `${head}/>`
197
+ if (r.status === "skipped") return `${head}><skipped message="${esc(r.reason || "")}"/></testcase>`
198
+ const failed = r.checks.filter((c) => !c.ok)
199
+ const msg = r.reason || failed.map((c) => `${c.name}: ${c.detail || "failed"}`).join("; ")
200
+ return `${head}><${r.status === "error" ? "error" : "failure"} message="${esc(msg)}"/></testcase>`
201
+ })
202
+ const c = run.counts
203
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="${esc(set.name)}" tests="${run.results.length}" failures="${c.failed}" errors="${c.error}" skipped="${c.skipped}">\n${cases.join("\n")}\n</testsuite>\n`
204
+ }