@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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- 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.
|
package/src/api/plan.ts
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
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
|
+
}
|