keepgreen 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/SECURITY.md +41 -0
- package/action.yml +128 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +266 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +138 -0
- package/dist/config.js.map +1 -0
- package/dist/explore.d.ts +66 -0
- package/dist/explore.js +240 -0
- package/dist/explore.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +14 -0
- package/dist/init.js +35 -0
- package/dist/init.js.map +1 -0
- package/dist/newfiles.d.ts +6 -0
- package/dist/newfiles.js +29 -0
- package/dist/newfiles.js.map +1 -0
- package/dist/patch.d.ts +13 -0
- package/dist/patch.js +61 -0
- package/dist/patch.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/package.json +65 -4
- package/selfhost/.env.example +17 -0
- package/selfhost/README.md +46 -0
- package/selfhost/docker-compose.yml +49 -0
- package/selfhost/make-env.sh +19 -0
- package/templates/keepgreen-explore.yml +127 -0
- package/templates/keepgreen.yml +22 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- OpenRouter and other OpenAI-compatible endpoints in the GitHub Action: new `base-url`, `api-key`, `price-in` and `price-out` inputs (the template shows them commented out). The README has an OpenRouter section.
|
|
6
|
+
- `keepgreen explore` sets `KEEPGREEN=1` for the app and the tests, so a Playwright `webServer` can reuse the app KeepGreen started: `reuseExistingServer: !process.env.CI || !!process.env.KEEPGREEN`. Without it, Playwright stops in CI with "port already in use".
|
|
7
|
+
- Explore workflow template: a blank `max-flows` input now keeps the value from `.keepgreen.yml` (it used to force 3).
|
|
8
|
+
- A refused AI request (no credit, bad key) now prints a short reason and what to do, instead of a stack trace.
|
|
9
|
+
- First release published from CI with npm provenance (0.1.0 was published by hand).
|
|
10
|
+
|
|
11
|
+
## 0.1.0
|
|
12
|
+
|
|
13
|
+
First release.
|
|
14
|
+
|
|
15
|
+
- `keepgreen explore`: looks at your app (playwright-e2e-mcp `inspect-page`), reads which tests already exist, asks your AI for the most important user flows with no test, and writes a test for each with AutoHeal's `cover`. A test is kept only if it passes the new-test guard (real assertions, no fakes, skips or fixed waits) and runs green several times in a row. One spend cap covers the whole run.
|
|
16
|
+
- `keepgreen heal`: AutoHeal's local `fix` (run the tests, fix what broke, leave the changes for review).
|
|
17
|
+
- `keepgreen init`: writes `.keepgreen.yml`, the explore workflow (read-only explore job + publish job that only accepts new test files) and AutoHeal's heal workflow.
|
|
18
|
+
- `keepgreen doctor`, `keepgreen autoheal <command>`, `keepgreen check-patch`.
|
|
19
|
+
- GitHub Action (`xsignon-secure/keepgreen@v0.1.0`) and a Docker Compose bundle for the self-hosted history server and AutoHeal dashboard.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 XSignOn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,179 @@
|
|
|
1
|
-
#
|
|
1
|
+
# KeepGreen
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Your AI test agent that writes, fixes and keeps your end-to-end tests green.**
|
|
4
|
+
|
|
5
|
+
KeepGreen is free and MIT licensed. It runs on your own CI runner or laptop with your own AI key
|
|
6
|
+
(Anthropic, OpenAI or any OpenAI-compatible endpoint, including local models). There is no
|
|
7
|
+
KeepGreen server, no account and no telemetry. You pay only your AI provider, and one spend cap
|
|
8
|
+
covers every run.
|
|
9
|
+
|
|
10
|
+
It combines two XSignOn projects into one agent:
|
|
11
|
+
|
|
12
|
+
- [playwright-e2e-mcp](https://github.com/xsignon-secure/E2E) is the toolbox. It opens pages,
|
|
13
|
+
finds stable locators, runs tests and reads failures.
|
|
14
|
+
- [AutoHeal](https://github.com/xsignon-secure/AutoHeal-AI) is the guarded fixer and test writer,
|
|
15
|
+
with its anti-cheat guard and stability proof.
|
|
16
|
+
|
|
17
|
+
| Recipe | What it does | Command |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| **Explore** | Looks at your app, picks the most important user flows that have **no test yet**, and writes a test for each. A test is kept only if it makes real assertions and passes every run (5 by default). | `npx keepgreen explore` |
|
|
20
|
+
| **Heal** | Runs your tests, finds what broke, fixes it and proves the fix. App bugs are reported, not "fixed". | `npx keepgreen heal` (local) · AutoHeal workflow (CI) |
|
|
21
|
+
|
|
22
|
+
Works with Playwright Test and pytest-playwright. Healing also covers Cypress and WebdriverIO.
|
|
23
|
+
|
|
24
|
+
## Quick start (your machine)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -D keepgreen # or use npx keepgreen ... without installing
|
|
28
|
+
export ANTHROPIC_API_KEY=sk-ant-... # your own key
|
|
29
|
+
npx keepgreen init # writes .keepgreen.yml + workflows, checks your setup
|
|
30
|
+
npx keepgreen explore --url http://localhost:3000
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Explore prints a table of the flows it tried and which tests it kept. New files are named
|
|
34
|
+
`tests/keepgreen-<flow>.spec.ts`. Review them with `git diff` and commit the ones you like.
|
|
35
|
+
|
|
36
|
+
Try it without writing anything first:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx keepgreen explore --url http://localhost:3000 --dry-run # plan only: shows the flows it would test
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Let KeepGreen start your app:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx keepgreen explore --start-command "npm run dev" --start-url http://localhost:3000
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Fix broken tests:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx keepgreen heal # = AutoHeal's `autoheal fix`; changes stay in your working tree for review
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## In GitHub Actions
|
|
55
|
+
|
|
56
|
+
`npx keepgreen init` writes two workflows:
|
|
57
|
+
|
|
58
|
+
- **`.github/workflows/keepgreen-explore.yml`** runs from *Actions > KeepGreen explore > Run
|
|
59
|
+
workflow*, or weekly if you uncomment the schedule. It opens **one PR** with the new tests that
|
|
60
|
+
passed. It has two jobs. The explore job runs the AI-written tests with a read-only token. The
|
|
61
|
+
publish job accepts the result only if it just adds test files, then opens the PR.
|
|
62
|
+
- **`.github/workflows/autoheal.yml`** is written by AutoHeal's own `init`. It runs after your
|
|
63
|
+
test workflow fails and opens a fix PR.
|
|
64
|
+
|
|
65
|
+
Then:
|
|
66
|
+
|
|
67
|
+
1. Add `ANTHROPIC_API_KEY` as a repository secret. For OpenAI, add `OPENAI_API_KEY` and a
|
|
68
|
+
repository variable `AUTOHEAL_MODEL`.
|
|
69
|
+
2. Go to *Settings > Actions > General > Workflow permissions* and tick "Allow GitHub Actions to
|
|
70
|
+
create and approve pull requests".
|
|
71
|
+
3. Make the explore job's install steps match your test workflow (Node or Python, browsers,
|
|
72
|
+
services).
|
|
73
|
+
|
|
74
|
+
You can also use the action directly:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
- uses: xsignon-secure/keepgreen@v0.1.1
|
|
78
|
+
with:
|
|
79
|
+
url: http://localhost:3000
|
|
80
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
81
|
+
# leaves keepgreen-out/ (report.md, summary.json, new-tests.patch); output `written` = tests kept
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Settings
|
|
85
|
+
|
|
86
|
+
`.keepgreen.yml` at the repo root. Command-line flags override it.
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
url: http://localhost:3000 # the running app, or:
|
|
90
|
+
# start-command: npm run dev
|
|
91
|
+
# start-url: http://localhost:3000
|
|
92
|
+
pages: # extra pages to look at when choosing flows
|
|
93
|
+
- /login
|
|
94
|
+
- /pricing
|
|
95
|
+
max-flows: 3 # new tests per explore run (1-10)
|
|
96
|
+
max-usd: 3 # AI spend cap for one explore run
|
|
97
|
+
runs: 5 # passing runs in a row a new test needs (2-20)
|
|
98
|
+
framework: auto # auto | playwright | pytest | cypress | webdriverio
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
AI settings use AutoHeal's variables:
|
|
102
|
+
|
|
103
|
+
| Variable | Meaning |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `ANTHROPIC_API_KEY` | Use Anthropic (default model `claude-opus-5-5`) |
|
|
106
|
+
| `OPENAI_API_KEY` + `AUTOHEAL_MODEL` | Use OpenAI |
|
|
107
|
+
| `AUTOHEAL_BASE_URL` + `AUTOHEAL_MODEL` (+ `AUTOHEAL_API_KEY`) | Any OpenAI-compatible endpoint: OpenRouter, or Ollama at `http://localhost:11434/v1` |
|
|
108
|
+
| `AUTOHEAL_EFFORT` | Anthropic effort: `low` … `max` (default `medium`) |
|
|
109
|
+
| `AUTOHEAL_PRICE_IN` / `AUTOHEAL_PRICE_OUT` | USD per 1M tokens, for models without a listed price |
|
|
110
|
+
|
|
111
|
+
### OpenRouter (or any OpenAI-compatible endpoint)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
export AUTOHEAL_BASE_URL=https://openrouter.ai/api/v1
|
|
115
|
+
export AUTOHEAL_API_KEY=sk-or-... # your OpenRouter key; give it a credit limit
|
|
116
|
+
export AUTOHEAL_MODEL=<model id from openrouter.ai/models>
|
|
117
|
+
export AUTOHEAL_PRICE_IN=3 AUTOHEAL_PRICE_OUT=15 # USD per 1M tokens of that model, so max-usd is enforced
|
|
118
|
+
npx keepgreen explore --url http://localhost:3000
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
In the Action: `base-url: https://openrouter.ai/api/v1`, `api-key: ${{ secrets.OPENROUTER_API_KEY }}`,
|
|
122
|
+
`model: ...`, and `price-in` / `price-out`.
|
|
123
|
+
|
|
124
|
+
### Playwright `webServer`
|
|
125
|
+
|
|
126
|
+
If your `playwright.config` starts the app with `webServer`, and KeepGreen starts it too
|
|
127
|
+
(`start-command`), let Playwright reuse it during a KeepGreen run:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
webServer: { command: 'npm run start', url: 'http://127.0.0.1:3000/', reuseExistingServer: !process.env.CI || !!process.env.KEEPGREEN },
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## How explore works
|
|
134
|
+
|
|
135
|
+
1. **Look.** It opens your app's start page and every path in `pages:` with playwright-e2e-mcp's
|
|
136
|
+
`inspect-page`, which lists the interactive elements and their locators.
|
|
137
|
+
2. **Plan.** It reads the titles of your existing tests and asks your AI for the most important
|
|
138
|
+
user flows that have no test yet. Flows that pay money, send email or delete data are skipped.
|
|
139
|
+
3. **Write.** For each flow, AutoHeal's `cover` explores the pages, checks every locator, and
|
|
140
|
+
writes one test in your project's own style.
|
|
141
|
+
4. **Prove.** The new-test guard rejects tests that cannot fail, or that use skips, faked network
|
|
142
|
+
answers, fixed waits, forced clicks or retries. The test must then pass every run in a row.
|
|
143
|
+
Anything less is thrown away.
|
|
144
|
+
|
|
145
|
+
One `max-usd` cap covers the whole run. Each flow gets an even share of what is left, so money one
|
|
146
|
+
flow saves goes to the next.
|
|
147
|
+
|
|
148
|
+
## All commands
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
keepgreen init [--force] [--dry-run]
|
|
152
|
+
keepgreen doctor
|
|
153
|
+
keepgreen explore [--url <app>] [--start-command <cmd> --start-url <url>] [--pages /a,/b]
|
|
154
|
+
[--max-flows 3] [--max-usd 3] [--runs 5] [--dry-run] [--out <dir>]
|
|
155
|
+
keepgreen heal [AutoHeal fix options]
|
|
156
|
+
keepgreen autoheal <command> [...] # any AutoHeal command: learn, metrics, dashboard, ...
|
|
157
|
+
keepgreen check-patch <file> # CI publish step
|
|
158
|
+
keepgreen --version
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Self-host bundle (optional)
|
|
162
|
+
|
|
163
|
+
KeepGreen needs no server. If you want **test history across all CI runs** (flaky ranking, the
|
|
164
|
+
commit where a test went bad) and **AutoHeal's dashboard** (fixes, blocked cheats, AI spend),
|
|
165
|
+
[`selfhost/`](selfhost/) starts both servers with one command:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
cd selfhost && sh make-env.sh your-org/your-repo && docker compose up -d --build
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Security
|
|
172
|
+
|
|
173
|
+
See [SECURITY.md](SECURITY.md). In short: your key goes only to your AI provider, AI-written tests
|
|
174
|
+
never run with a write token or your AI key, the publish step only accepts new test files, and
|
|
175
|
+
every change arrives as a PR for a person to review.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT. Free for any use, with no paid tier.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
KeepGreen runs an AI model against your app and your tests, so it is built on the assumption that
|
|
4
|
+
the model can be wrong or manipulated. Text on a page or in a test file could try to instruct it;
|
|
5
|
+
every prompt marks such text as data.
|
|
6
|
+
|
|
7
|
+
## What protects you
|
|
8
|
+
|
|
9
|
+
- **Your key, your runner.** KeepGreen has no server, no account and no telemetry. The only
|
|
10
|
+
network calls go to the AI provider you choose, your own app, and (in CI) GitHub to open the PR.
|
|
11
|
+
- **Two jobs in CI.** The explore job, where AI-written tests run, has a read-only token and
|
|
12
|
+
checks out with `persist-credentials: false`. It uploads the new tests as a patch. The publish
|
|
13
|
+
job never runs your project's code. `keepgreen check-patch` accepts the patch only if it just
|
|
14
|
+
**adds** plain test files (`*.spec.ts`, `*.test.js`, `*.cy.ts`, `test_*.py`, ...): no changes
|
|
15
|
+
to existing files, no deletes, renames, mode changes or binary data, nothing under hidden
|
|
16
|
+
folders such as `.github/`, nothing in `node_modules`, at most 10 files. Then git applies it
|
|
17
|
+
with hooks off and the PR is opened.
|
|
18
|
+
- **Secrets stay out of test runs.** New tests, and an app started with `start-command`, run in a
|
|
19
|
+
child process without the AI key or any GitHub / Actions token (AutoHeal's scrubbed engine
|
|
20
|
+
environment).
|
|
21
|
+
- **New-test guard.** AutoHeal's `scanNewTest` rejects tests without real assertions, or with
|
|
22
|
+
skips, faked network answers (`page.route`), injected scripts, fixed waits, `force: true`,
|
|
23
|
+
extra retries or timeouts, file, environment or network access. A test must then pass every
|
|
24
|
+
one of `runs` runs in a row. The scans are pattern based, so every test still arrives as a PR
|
|
25
|
+
for a person to review.
|
|
26
|
+
- **Pages stay on your app.** Planning only opens the app URL and the paths you list in
|
|
27
|
+
`pages:`; `cover` only opens pages on the same origin.
|
|
28
|
+
- **Spend cap.** One `max-usd` limit covers a whole explore run (planning plus every flow).
|
|
29
|
+
|
|
30
|
+
## Your part
|
|
31
|
+
|
|
32
|
+
- Use an AI key with a spend limit; it is present in the explore job.
|
|
33
|
+
- Run explore on your own branches only. The template runs on `workflow_dispatch` and an
|
|
34
|
+
optional schedule, never on pull requests from forks.
|
|
35
|
+
- Review every KeepGreen PR like any other change.
|
|
36
|
+
- For the self-host bundle, put the servers behind HTTPS and keep `.env` out of git.
|
|
37
|
+
|
|
38
|
+
## Reporting a vulnerability
|
|
39
|
+
|
|
40
|
+
Please use GitHub's private vulnerability reporting on this repository
|
|
41
|
+
(Security > Report a vulnerability). Do not open a public issue.
|
package/action.yml
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
name: KeepGreen explore
|
|
2
|
+
description: Free, self-hosted AI test agent. Finds important user flows with no test and writes Playwright tests for them, with your own AI key.
|
|
3
|
+
author: xsignon-secure
|
|
4
|
+
branding:
|
|
5
|
+
icon: check-circle
|
|
6
|
+
color: green
|
|
7
|
+
|
|
8
|
+
# Runs `keepgreen explore` and leaves keepgreen-out/ (report.md, summary.json,
|
|
9
|
+
# new-tests.patch) for an upload step. It opens no PR itself: the template's
|
|
10
|
+
# publish job does that with a separate, write-only step. See
|
|
11
|
+
# templates/keepgreen-explore.yml.
|
|
12
|
+
|
|
13
|
+
inputs:
|
|
14
|
+
url:
|
|
15
|
+
description: App URL to explore (blank = url in .keepgreen.yml)
|
|
16
|
+
required: false
|
|
17
|
+
default: ""
|
|
18
|
+
start-command:
|
|
19
|
+
description: Command that starts your app (with start-url)
|
|
20
|
+
required: false
|
|
21
|
+
default: ""
|
|
22
|
+
start-url:
|
|
23
|
+
description: Address to wait for after start-command
|
|
24
|
+
required: false
|
|
25
|
+
default: ""
|
|
26
|
+
max-flows:
|
|
27
|
+
description: New tests to try (1-10; blank = .keepgreen.yml or 3)
|
|
28
|
+
required: false
|
|
29
|
+
default: ""
|
|
30
|
+
max-usd:
|
|
31
|
+
description: AI spend cap for the run in USD (blank = .keepgreen.yml or 3)
|
|
32
|
+
required: false
|
|
33
|
+
default: ""
|
|
34
|
+
runs:
|
|
35
|
+
description: Passing runs in a row a new test needs (blank = .keepgreen.yml or 5)
|
|
36
|
+
required: false
|
|
37
|
+
default: ""
|
|
38
|
+
anthropic-api-key:
|
|
39
|
+
description: Anthropic API key
|
|
40
|
+
required: false
|
|
41
|
+
default: ""
|
|
42
|
+
openai-api-key:
|
|
43
|
+
description: OpenAI API key (also set model)
|
|
44
|
+
required: false
|
|
45
|
+
default: ""
|
|
46
|
+
model:
|
|
47
|
+
description: Model id (optional for Anthropic; for OpenRouter e.g. the id shown on openrouter.ai/models)
|
|
48
|
+
required: false
|
|
49
|
+
default: ""
|
|
50
|
+
base-url:
|
|
51
|
+
description: OpenAI-compatible endpoint, e.g. https://openrouter.ai/api/v1 (use with api-key and model)
|
|
52
|
+
required: false
|
|
53
|
+
default: ""
|
|
54
|
+
api-key:
|
|
55
|
+
description: API key for base-url (e.g. your OpenRouter key)
|
|
56
|
+
required: false
|
|
57
|
+
default: ""
|
|
58
|
+
price-in:
|
|
59
|
+
description: USD per 1M input tokens, for models KeepGreen has no price for (enforces max-usd)
|
|
60
|
+
required: false
|
|
61
|
+
default: ""
|
|
62
|
+
price-out:
|
|
63
|
+
description: USD per 1M output tokens
|
|
64
|
+
required: false
|
|
65
|
+
default: ""
|
|
66
|
+
working-directory:
|
|
67
|
+
description: Project folder
|
|
68
|
+
required: false
|
|
69
|
+
default: "."
|
|
70
|
+
version:
|
|
71
|
+
description: keepgreen version from npm
|
|
72
|
+
required: false
|
|
73
|
+
default: "0.1.1"
|
|
74
|
+
|
|
75
|
+
outputs:
|
|
76
|
+
written:
|
|
77
|
+
description: Number of new tests kept
|
|
78
|
+
value: ${{ steps.run.outputs.written }}
|
|
79
|
+
|
|
80
|
+
runs:
|
|
81
|
+
using: composite
|
|
82
|
+
steps:
|
|
83
|
+
- id: run
|
|
84
|
+
shell: bash
|
|
85
|
+
working-directory: ${{ inputs.working-directory }}
|
|
86
|
+
env:
|
|
87
|
+
ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }}
|
|
88
|
+
OPENAI_API_KEY: ${{ inputs.openai-api-key }}
|
|
89
|
+
AUTOHEAL_MODEL: ${{ inputs.model }}
|
|
90
|
+
KG_BASE_URL: ${{ inputs.base-url }}
|
|
91
|
+
KG_API_KEY: ${{ inputs.api-key }}
|
|
92
|
+
KG_PRICE_IN: ${{ inputs.price-in }}
|
|
93
|
+
KG_PRICE_OUT: ${{ inputs.price-out }}
|
|
94
|
+
KG_VERSION: ${{ inputs.version }}
|
|
95
|
+
KG_URL: ${{ inputs.url }}
|
|
96
|
+
KG_START_COMMAND: ${{ inputs.start-command }}
|
|
97
|
+
KG_START_URL: ${{ inputs.start-url }}
|
|
98
|
+
KG_MAX_FLOWS: ${{ inputs.max-flows }}
|
|
99
|
+
KG_MAX_USD: ${{ inputs.max-usd }}
|
|
100
|
+
KG_RUNS: ${{ inputs.runs }}
|
|
101
|
+
OUT: ${{ github.workspace }}/keepgreen-out
|
|
102
|
+
run: |
|
|
103
|
+
[ -n "$ANTHROPIC_API_KEY" ] || unset ANTHROPIC_API_KEY
|
|
104
|
+
[ -n "$OPENAI_API_KEY" ] || unset OPENAI_API_KEY
|
|
105
|
+
[ -n "$AUTOHEAL_MODEL" ] || unset AUTOHEAL_MODEL
|
|
106
|
+
# Inputs win; otherwise keep any AUTOHEAL_* already set on the job.
|
|
107
|
+
if [ -n "$KG_BASE_URL" ]; then export AUTOHEAL_PROVIDER=openai-compatible AUTOHEAL_BASE_URL="$KG_BASE_URL"; fi
|
|
108
|
+
if [ -n "$KG_API_KEY" ]; then export AUTOHEAL_API_KEY="$KG_API_KEY"; fi
|
|
109
|
+
if [ -n "$KG_PRICE_IN" ]; then export AUTOHEAL_PRICE_IN="$KG_PRICE_IN"; fi
|
|
110
|
+
if [ -n "$KG_PRICE_OUT" ]; then export AUTOHEAL_PRICE_OUT="$KG_PRICE_OUT"; fi
|
|
111
|
+
unset KG_API_KEY
|
|
112
|
+
if ! [[ "$KG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::version must be like 1.2.3"; exit 2; fi
|
|
113
|
+
args=(explore --out "$OUT")
|
|
114
|
+
[ -n "$KG_URL" ] && args+=(--url "$KG_URL")
|
|
115
|
+
[ -n "$KG_START_COMMAND" ] && args+=(--start-command "$KG_START_COMMAND")
|
|
116
|
+
[ -n "$KG_START_URL" ] && args+=(--start-url "$KG_START_URL")
|
|
117
|
+
[ -n "$KG_MAX_FLOWS" ] && args+=(--max-flows "$KG_MAX_FLOWS")
|
|
118
|
+
[ -n "$KG_MAX_USD" ] && args+=(--max-usd "$KG_MAX_USD")
|
|
119
|
+
[ -n "$KG_RUNS" ] && args+=(--runs "$KG_RUNS")
|
|
120
|
+
set +e
|
|
121
|
+
npx -y "keepgreen@$KG_VERSION" "${args[@]}"
|
|
122
|
+
code=$?
|
|
123
|
+
set -e
|
|
124
|
+
written=0
|
|
125
|
+
[ -f "$OUT/summary.json" ] && written="$(node -p 'require(process.env.OUT + "/summary.json").written.length')"
|
|
126
|
+
echo "written=$written" >> "$GITHUB_OUTPUT"
|
|
127
|
+
# 1 = ran fine but kept no test; that is not a failed job.
|
|
128
|
+
if [ "$code" -gt 1 ] || { [ "$code" -eq 1 ] && [ ! -f "$OUT/summary.json" ]; }; then exit "$code"; fi
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* keepgreen: one command for the whole agent.
|
|
4
|
+
* explore writes new tests (KeepGreen's own recipe, built on AutoHeal's cover)
|
|
5
|
+
* heal fixes broken tests on your machine (AutoHeal's `fix`)
|
|
6
|
+
* init writes .keepgreen.yml, the explore workflow and AutoHeal's heal workflow
|
|
7
|
+
* doctor checks the setup
|
|
8
|
+
*/
|
|
9
|
+
export declare const USAGE: string;
|
|
10
|
+
/** Path of AutoHeal's CLI inside the installed xsignon-autoheal package. */
|
|
11
|
+
export declare function autohealCli(): string;
|
|
12
|
+
export declare function main(argv: string[]): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* keepgreen: one command for the whole agent.
|
|
4
|
+
* explore writes new tests (KeepGreen's own recipe, built on AutoHeal's cover)
|
|
5
|
+
* heal fixes broken tests on your machine (AutoHeal's `fix`)
|
|
6
|
+
* init writes .keepgreen.yml, the explore workflow and AutoHeal's heal workflow
|
|
7
|
+
* doctor checks the setup
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { realpathSync } from 'node:fs';
|
|
11
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
+
import { parseArgs } from 'node:util';
|
|
15
|
+
import { ConfigError, CypressTools, McpTools, ProviderError, WdioTools, checkAppUrl, cover, frameworkSetting, isCypressProject, isPytestProject, isWdioProject, priceFor, providerFromEnv, startApp, } from 'xsignon-autoheal';
|
|
16
|
+
import { ConfigFileError, loadConfigFile, resolveConfig } from './config.js';
|
|
17
|
+
import { existingTitles, explore, exploreReport } from './explore.js';
|
|
18
|
+
import { init } from './init.js';
|
|
19
|
+
import { newFilesPatch } from './newfiles.js';
|
|
20
|
+
import { checkPatch } from './patch.js';
|
|
21
|
+
import { VERSION } from './version.js';
|
|
22
|
+
export const USAGE = `keepgreen ${VERSION}: free, self-hosted AI test agent. It writes new end-to-end tests
|
|
23
|
+
for your app and fixes the ones that break, with your own AI key (Anthropic, OpenAI or any
|
|
24
|
+
OpenAI-compatible endpoint). Built on playwright-e2e-mcp and AutoHeal. MIT, no account, no telemetry.
|
|
25
|
+
|
|
26
|
+
keepgreen init [--force] [--dry-run]
|
|
27
|
+
write .keepgreen.yml, the explore workflow and AutoHeal's heal workflow (GitHub Actions)
|
|
28
|
+
keepgreen doctor
|
|
29
|
+
check this project and machine
|
|
30
|
+
keepgreen explore [--url <app>] [--start-command <cmd> --start-url <url>] [--pages /a,/b]
|
|
31
|
+
[--max-flows 3] [--max-usd 3] [--runs 5] [--dry-run] [--out <dir>]
|
|
32
|
+
find important user flows with no test yet and write a test for each; a test is kept
|
|
33
|
+
only if it makes real assertions and passes --runs times in a row
|
|
34
|
+
keepgreen heal [AutoHeal fix options]
|
|
35
|
+
run your tests, fix what broke, leave the changes for you to review
|
|
36
|
+
keepgreen autoheal <command> [...]
|
|
37
|
+
run any AutoHeal command (heal, publish, learn, metrics, dashboard, ...)
|
|
38
|
+
keepgreen check-patch <file>
|
|
39
|
+
CI publish step: accept a patch only if it just adds test files
|
|
40
|
+
keepgreen --version
|
|
41
|
+
|
|
42
|
+
Settings come from .keepgreen.yml; flags win. The AI key comes from ANTHROPIC_API_KEY,
|
|
43
|
+
OPENAI_API_KEY (+ AUTOHEAL_MODEL) or AUTOHEAL_BASE_URL (+ AUTOHEAL_MODEL).
|
|
44
|
+
`;
|
|
45
|
+
const log = (line) => process.stderr.write(`[keepgreen] ${line}\n`);
|
|
46
|
+
/** Path of AutoHeal's CLI inside the installed xsignon-autoheal package. */
|
|
47
|
+
export function autohealCli() {
|
|
48
|
+
const entry = fileURLToPath(import.meta.resolve('xsignon-autoheal'));
|
|
49
|
+
return path.join(path.dirname(entry), 'cli.js');
|
|
50
|
+
}
|
|
51
|
+
function runAutoheal(args) {
|
|
52
|
+
const r = spawnSync(process.execPath, [autohealCli(), ...args], { stdio: 'inherit' });
|
|
53
|
+
if (r.error)
|
|
54
|
+
throw r.error;
|
|
55
|
+
return r.status ?? 1;
|
|
56
|
+
}
|
|
57
|
+
async function detectFramework(root, value) {
|
|
58
|
+
let setting;
|
|
59
|
+
try {
|
|
60
|
+
setting = frameworkSetting(value ?? process.env.AUTOHEAL_FRAMEWORK);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new ConfigError(error instanceof Error ? error.message : String(error));
|
|
64
|
+
}
|
|
65
|
+
if (setting !== 'auto')
|
|
66
|
+
return setting;
|
|
67
|
+
if (await isWdioProject(root))
|
|
68
|
+
return 'webdriverio';
|
|
69
|
+
if (await isCypressProject(root))
|
|
70
|
+
return 'cypress';
|
|
71
|
+
return (await isPytestProject(root)) ? 'pytest' : 'playwright';
|
|
72
|
+
}
|
|
73
|
+
async function cmdExplore(argv) {
|
|
74
|
+
const { values } = parseArgs({
|
|
75
|
+
args: argv,
|
|
76
|
+
options: {
|
|
77
|
+
url: { type: 'string' },
|
|
78
|
+
'start-command': { type: 'string' },
|
|
79
|
+
'start-url': { type: 'string' },
|
|
80
|
+
pages: { type: 'string' },
|
|
81
|
+
'max-flows': { type: 'string' },
|
|
82
|
+
'max-usd': { type: 'string' },
|
|
83
|
+
runs: { type: 'string' },
|
|
84
|
+
framework: { type: 'string' },
|
|
85
|
+
project: { type: 'string' },
|
|
86
|
+
out: { type: 'string' },
|
|
87
|
+
'dry-run': { type: 'boolean' },
|
|
88
|
+
},
|
|
89
|
+
strict: true,
|
|
90
|
+
});
|
|
91
|
+
const projectRoot = path.resolve(values.project ?? '.');
|
|
92
|
+
const { project: _p, out: _o, 'dry-run': _d, ...flags } = values;
|
|
93
|
+
const config = resolveConfig(await loadConfigFile(projectRoot), flags);
|
|
94
|
+
const env = process.env;
|
|
95
|
+
// Lets playwright.config tell a KeepGreen run apart, e.g.
|
|
96
|
+
// reuseExistingServer: !process.env.CI || !!process.env.KEEPGREEN
|
|
97
|
+
env.KEEPGREEN = '1';
|
|
98
|
+
const provider = providerFromEnv(env);
|
|
99
|
+
if (!provider)
|
|
100
|
+
throw new ConfigError('keepgreen explore needs an AI key: set ANTHROPIC_API_KEY, OPENAI_API_KEY (and AUTOHEAL_MODEL) or AUTOHEAL_BASE_URL.');
|
|
101
|
+
const framework = await detectFramework(projectRoot, config.framework);
|
|
102
|
+
let app = null;
|
|
103
|
+
let appUrl = config.url;
|
|
104
|
+
let engine = null;
|
|
105
|
+
let runner = null;
|
|
106
|
+
try {
|
|
107
|
+
if (config.startCommand) {
|
|
108
|
+
const url = config.startUrl ?? config.url;
|
|
109
|
+
if (!url)
|
|
110
|
+
throw new ConfigError('start-command needs start-url: the address to wait for.');
|
|
111
|
+
// startApp gives your app the tests' scrubbed environment: no AI key, no CI tokens.
|
|
112
|
+
app = await startApp({ command: config.startCommand, url, cwd: projectRoot, sandbox: null, log });
|
|
113
|
+
appUrl = app.url;
|
|
114
|
+
}
|
|
115
|
+
if (!appUrl)
|
|
116
|
+
throw new ConfigError('Tell KeepGreen where your app runs: --url http://localhost:3000, or url: in .keepgreen.yml (or start-command + start-url).');
|
|
117
|
+
checkAppUrl(appUrl);
|
|
118
|
+
log(`KeepGreen ${VERSION}: exploring ${appUrl} (${framework}, ${provider.name} ${provider.model}, limit $${config.maxUsd})`);
|
|
119
|
+
engine = await McpTools.start(projectRoot);
|
|
120
|
+
runner = framework === 'cypress' ? new CypressTools(projectRoot, { log }) : framework === 'webdriverio' ? new WdioTools(projectRoot, { log }) : engine;
|
|
121
|
+
const result = await explore({
|
|
122
|
+
projectRoot,
|
|
123
|
+
provider,
|
|
124
|
+
engine,
|
|
125
|
+
runner,
|
|
126
|
+
appUrl,
|
|
127
|
+
pages: config.pages,
|
|
128
|
+
existing: await existingTitles(projectRoot),
|
|
129
|
+
maxFlows: config.maxFlows,
|
|
130
|
+
maxUsd: config.maxUsd,
|
|
131
|
+
runs: config.runs,
|
|
132
|
+
price: priceFor(provider.model, env),
|
|
133
|
+
framework,
|
|
134
|
+
cover,
|
|
135
|
+
log,
|
|
136
|
+
dryRun: values['dry-run'] === true,
|
|
137
|
+
});
|
|
138
|
+
const report = exploreReport(result, config.runs);
|
|
139
|
+
process.stdout.write(`\n${report}\n`);
|
|
140
|
+
const written = result.results.filter((r) => r.result.status === 'written').map((r) => r.result.file);
|
|
141
|
+
if (values.out) {
|
|
142
|
+
const out = path.resolve(values.out);
|
|
143
|
+
await mkdir(out, { recursive: true });
|
|
144
|
+
await writeFile(path.join(out, 'report.md'), `${report}\n`);
|
|
145
|
+
await writeFile(path.join(out, 'summary.json'), `${JSON.stringify({ version: VERSION, written, flows: result.flows, usd: result.usd }, null, 2)}\n`);
|
|
146
|
+
await writeFile(path.join(out, 'new-tests.patch'), await newFilesPatch(projectRoot, written));
|
|
147
|
+
log(`Wrote ${path.relative(process.cwd(), out) || '.'}/report.md, summary.json and new-tests.patch`);
|
|
148
|
+
}
|
|
149
|
+
if (env.GITHUB_STEP_SUMMARY)
|
|
150
|
+
await appendFile(env.GITHUB_STEP_SUMMARY, `${report}\n`).catch(() => undefined);
|
|
151
|
+
return values['dry-run'] || written.length ? 0 : 1;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (runner && runner !== engine)
|
|
155
|
+
await runner.close().catch(() => undefined);
|
|
156
|
+
await engine?.close();
|
|
157
|
+
await app?.stop().catch(() => undefined);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function cmdInit(argv) {
|
|
161
|
+
const { values } = parseArgs({ args: argv, options: { project: { type: 'string' }, force: { type: 'boolean' }, 'dry-run': { type: 'boolean' } }, strict: true });
|
|
162
|
+
const root = path.resolve(values.project ?? '.');
|
|
163
|
+
const dryRun = values['dry-run'] === true;
|
|
164
|
+
const r = await init(root, { force: values.force === true, dryRun });
|
|
165
|
+
const out = [`KeepGreen ${VERSION}`, ''];
|
|
166
|
+
for (const w of r.written)
|
|
167
|
+
out.push(`${dryRun ? 'would write' : 'wrote'} ${w}`);
|
|
168
|
+
for (const w of r.skipped)
|
|
169
|
+
out.push(`kept ${w} (exists; --force to overwrite)`);
|
|
170
|
+
out.push('', 'Now AutoHeal sets up healing:', '');
|
|
171
|
+
process.stdout.write(out.join('\n'));
|
|
172
|
+
const code = runAutoheal(['init', '--ci', 'github', '--project', root, ...(values.force ? ['--force'] : []), ...(dryRun ? ['--dry-run'] : [])]);
|
|
173
|
+
process.stdout.write([
|
|
174
|
+
'',
|
|
175
|
+
'For KeepGreen explore, also:',
|
|
176
|
+
' 1. Set url: in .keepgreen.yml (or start-command + start-url) so KeepGreen can reach your app.',
|
|
177
|
+
' 2. Add ANTHROPIC_API_KEY (or OPENAI_API_KEY + the AUTOHEAL_MODEL variable) as a repository secret.',
|
|
178
|
+
' 3. Settings > Actions > General: allow GitHub Actions to create pull requests.',
|
|
179
|
+
' 4. Run it: Actions > KeepGreen explore > Run workflow (or `npx keepgreen explore` on your machine).',
|
|
180
|
+
'',
|
|
181
|
+
].join('\n'));
|
|
182
|
+
return code;
|
|
183
|
+
}
|
|
184
|
+
async function cmdDoctor(argv) {
|
|
185
|
+
const { values } = parseArgs({ args: argv, options: { project: { type: 'string' } }, strict: true });
|
|
186
|
+
const root = path.resolve(values.project ?? '.');
|
|
187
|
+
const lines = [`KeepGreen ${VERSION} doctor`, ''];
|
|
188
|
+
let failed = false;
|
|
189
|
+
try {
|
|
190
|
+
const config = resolveConfig(await loadConfigFile(root), {});
|
|
191
|
+
const target = config.startCommand ? `start-command "${config.startCommand}" then ${config.startUrl ?? config.url ?? '(no start-url!)'}` : (config.url ?? null);
|
|
192
|
+
lines.push(target ? `ok explore target: ${target}` : 'warn no url in .keepgreen.yml: pass --url to keepgreen explore');
|
|
193
|
+
if (config.startCommand && !(config.startUrl ?? config.url))
|
|
194
|
+
failed = true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
failed = true;
|
|
198
|
+
lines.push(`fail .keepgreen.yml: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
|
+
}
|
|
200
|
+
lines.push('', 'AutoHeal checks (keys, framework, CI):', '');
|
|
201
|
+
process.stdout.write(lines.join('\n'));
|
|
202
|
+
const code = runAutoheal(['doctor', '--project', root]);
|
|
203
|
+
return failed ? 1 : code;
|
|
204
|
+
}
|
|
205
|
+
async function cmdCheckPatch(argv) {
|
|
206
|
+
const file = argv[0];
|
|
207
|
+
if (!file || argv.length > 1)
|
|
208
|
+
throw new ConfigError('Usage: keepgreen check-patch <file>');
|
|
209
|
+
const result = checkPatch(await readFile(file, 'utf8'));
|
|
210
|
+
if (result.ok) {
|
|
211
|
+
process.stdout.write(`${result.files.join('\n')}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
log(result.files.length || result.problems.length ? `Patch refused:\n ${(result.problems.length ? result.problems : ['no files']).join('\n ')}` : 'Patch is empty.');
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
export async function main(argv) {
|
|
218
|
+
const [command, ...rest] = argv;
|
|
219
|
+
try {
|
|
220
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
221
|
+
process.stdout.write(USAGE);
|
|
222
|
+
return command ? 0 : 2;
|
|
223
|
+
}
|
|
224
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
225
|
+
process.stdout.write(`${VERSION}\n`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
if (command === 'explore')
|
|
229
|
+
return await cmdExplore(rest);
|
|
230
|
+
if (command === 'heal')
|
|
231
|
+
return runAutoheal(['fix', ...rest]);
|
|
232
|
+
if (command === 'autoheal')
|
|
233
|
+
return runAutoheal(rest);
|
|
234
|
+
if (command === 'init')
|
|
235
|
+
return await cmdInit(rest);
|
|
236
|
+
if (command === 'doctor')
|
|
237
|
+
return await cmdDoctor(rest);
|
|
238
|
+
if (command === 'check-patch')
|
|
239
|
+
return await cmdCheckPatch(rest);
|
|
240
|
+
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
|
241
|
+
return 2;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof ConfigError || error instanceof ConfigFileError || error.code?.startsWith('ERR_PARSE_ARGS')) {
|
|
245
|
+
log(error.message);
|
|
246
|
+
return 2;
|
|
247
|
+
}
|
|
248
|
+
if (error instanceof ProviderError) {
|
|
249
|
+
log(`The AI provider refused the request: ${error.message.slice(0, 600)}`);
|
|
250
|
+
if (/HTTP 402|credits|quota|insufficient/i.test(error.message))
|
|
251
|
+
log('Your AI account or key is out of credit (or its spend limit is too low). Add credit or raise the key\'s limit, then run again.');
|
|
252
|
+
else if (/HTTP 401|HTTP 403|api key|unauthori/i.test(error.message))
|
|
253
|
+
log('Check the API key (and AUTOHEAL_BASE_URL / AUTOHEAL_MODEL for OpenAI-compatible endpoints).');
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
log(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const invoked = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : '';
|
|
261
|
+
if (invoked === import.meta.url) {
|
|
262
|
+
main(process.argv.slice(2)).then((code) => {
|
|
263
|
+
process.exitCode = code;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=cli.js.map
|