Skip to content

Commit 2b12c5f

Browse files
Merge pull request #500 from rainforestapp/RF-39249-support-ai-test-generation
RF-39249 Add AI Test Generation Command
2 parents 05ce9cc + 8cba888 commit 2b12c5f

8 files changed

Lines changed: 488 additions & 30 deletions

File tree

.circleci/config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ windows-param: &windows-param
1111
executors:
1212
mac:
1313
macos:
14-
xcode: 15.4.0
15-
resource_class: macos.m1.medium.gen1
14+
xcode: 26.3.0
15+
resource_class: m4pro.medium
1616
linux:
1717
docker:
1818
- image: cimg/go:1.25.7-node

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Rainforest CLI Changelog
22

3+
## 3.8.0 - 2026-02-10
4+
- Add `generate` command for AI test generation
5+
- (72027b92a907c4b9cf060c4ea06c92b3c7b290ca, @sebaherrera07)
6+
37
## 3.7.0 - 2026-02-09
48
- Update Go from 1.22.3 to 1.25.7
59
- (e0dc14e8c0c327daf6884a0e69e93482d156adae, @sebaherrera07)

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,34 @@ Download specific tests based on their id on the Rainforest dashboard
195195
rainforest download 33445 11232 1337
196196
```
197197

198+
#### Generating Tests with AI
199+
200+
Generate a new test using AI based on a natural language prompt. `--title` and `--platform` are required. Commonly used platforms include: `windows10_chrome`, `windows11_chrome`, and `windows11_chrome_fhd`; unsupported values will be rejected by the Rainforest API. Note: AI test generation only supports one platform at a time.
201+
202+
```bash
203+
rainforest generate "Log in with valid credentials and verify the dashboard loads" --title "Login Flow" --start-uri /login --platform windows11_chrome
204+
```
205+
206+
You can also use a full URL instead of a start URI.
207+
208+
```bash
209+
rainforest generate "Add an item to the shopping cart" --title "Add to Cart" --url https://example.com/shop --platform windows11_chrome
210+
```
211+
212+
Provide credentials information for the AI to use during test generation. This is a free-form string passed to the AI model.
213+
214+
```bash
215+
rainforest generate "Log in as admin user" --title "Admin Login" --start-uri /admin --platform windows11_chrome --credentials "username: admin, password: secret123"
216+
```
217+
218+
Alternatively, use a login snippet for authentication.
219+
220+
```bash
221+
rainforest generate "Verify dashboard features" --title "Dashboard Features" --start-uri /dashboard --platform windows11_chrome --login-snippet-id 54321
222+
```
223+
224+
Note: `--credentials` and `--login-snippet-id` are mutually exclusive.
225+
198226
#### Running Local RFML Tests Only
199227

200228
If you want to run a local set of RFML files (for instance in a CI environment), use the `run -f` option:

rainforest-cli.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
const (
1515
// Version of the app in SemVer
16-
version = "3.7.0"
16+
version = "3.8.0"
1717
// This is the default spec folder for RFML tests
1818
defaultSpecFolder = "./spec/rainforest"
1919
)
@@ -359,6 +359,49 @@ func main() {
359359
return newRFMLTest(c)
360360
},
361361
},
362+
{
363+
Name: "generate",
364+
Aliases: []string{"gen", "ai"},
365+
Usage: "Generate a new test using AI",
366+
OnUsageError: onCommandUsageErrorHandler("generate"),
367+
ArgsUsage: "[prompt]",
368+
Description: "Generate a new Rainforest test using AI based on a natural language prompt. " +
369+
"The prompt should describe what the test should do. " +
370+
"Example: rainforest generate \"Log in with valid credentials and verify the dashboard loads\"",
371+
Flags: []cli.Flag{
372+
cli.StringFlag{
373+
Name: "title",
374+
Usage: "Title for the generated test.",
375+
},
376+
cli.StringFlag{
377+
Name: "start-uri",
378+
Usage: "The starting `URI` path for the test (e.g., /login).",
379+
},
380+
cli.StringFlag{
381+
Name: "url",
382+
Usage: "The full starting `URL` for the test (alternative to --start-uri).",
383+
},
384+
cli.StringFlag{
385+
Name: "platform",
386+
Usage: "Specify the `PLATFORM` to use for AI generation (e.g., windows10_chrome, windows11_chrome).",
387+
},
388+
cli.IntFlag{
389+
Name: "environment-id",
390+
Usage: "The `ENVIRONMENT-ID` to use for the test.",
391+
},
392+
cli.StringFlag{
393+
Name: "credentials",
394+
Usage: "Free-form credentials information to pass to the AI for test generation (e.g., \"username: admin, password: secret123\"). This is an opaque string passed to the AI model. Mutually exclusive with --login-snippet-id.",
395+
},
396+
cli.IntFlag{
397+
Name: "login-snippet-id",
398+
Usage: "The `ID` of a snippet to use for login steps. Mutually exclusive with --credentials.",
399+
},
400+
},
401+
Action: func(c *cli.Context) error {
402+
return generateAITest(c, api)
403+
},
404+
},
362405
{
363406
Name: "validate",
364407
Usage: "Validate your RFML tests",

rainforest/tests.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,71 @@ func (c *Client) UpdateTest(test *RFTest, branchID int) error {
533533
}
534534
return nil
535535
}
536+
537+
// AITestRequest represents the parameters needed to create a test using AI generation
538+
type AITestRequest struct {
539+
Title string `json:"title,omitempty"`
540+
Type string `json:"type"`
541+
StartURI string `json:"start_uri,omitempty"`
542+
FullURL string `json:"full_url,omitempty"`
543+
Prompt string `json:"prompt"`
544+
Browsers []string `json:"browsers,omitempty"`
545+
EnvironmentID int `json:"environment_id,omitempty"`
546+
PromptCredentials string `json:"prompt_credentials,omitempty"`
547+
LoginSnippetID int `json:"login_snippet_id,omitempty"`
548+
}
549+
550+
// AITestResponse represents the response from the AI test generation API
551+
type AITestResponse struct {
552+
TestID int `json:"id"`
553+
Title string `json:"title"`
554+
State string `json:"state"`
555+
}
556+
557+
// CreateTestWithAI creates a new test using AI generation
558+
func (c *Client) CreateTestWithAI(request *AITestRequest) (*AITestResponse, error) {
559+
// AI test generation only supports one browser at a time
560+
if len(request.Browsers) > 1 {
561+
return nil, errors.New("AI test generation only supports one browser at a time")
562+
}
563+
564+
// Ensure type is "test"
565+
if request.Type != "test" {
566+
return nil, errors.New("Type must be 'test' for AI test generation")
567+
}
568+
569+
// Ensure prompt is provided
570+
if request.Prompt == "" {
571+
return nil, errors.New("Prompt is required for AI test generation")
572+
}
573+
574+
// Ensure at least one of StartURI or FullURL is provided
575+
if request.StartURI == "" && request.FullURL == "" {
576+
return nil, errors.New("Either StartURI or FullURL must be provided for AI test generation")
577+
}
578+
579+
// Ensure StartURI and FullURL are mutually exclusive
580+
if request.StartURI != "" && request.FullURL != "" {
581+
return nil, errors.New("Only one of StartURI or FullURL may be provided for AI test generation")
582+
}
583+
584+
// Validate mutually exclusive login parameters
585+
if request.PromptCredentials != "" && request.LoginSnippetID > 0 {
586+
return nil, errors.New("Cannot specify both prompt_credentials and login_snippet_id. Choose one login method")
587+
}
588+
589+
// Prepare request
590+
req, err := c.NewRequest("POST", "tests", request)
591+
if err != nil {
592+
return nil, err
593+
}
594+
595+
// Send request and process response
596+
var response AITestResponse
597+
_, err = c.Do(req, &response)
598+
if err != nil {
599+
return nil, err
600+
}
601+
602+
return &response, nil
603+
}

rainforest/tests_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,111 @@ func TestHasUploadableFiles(t *testing.T) {
387387
}
388388
}
389389

390+
func TestCreateTestWithAI(t *testing.T) {
391+
setup()
392+
defer cleanup()
393+
394+
mux.HandleFunc("/tests", func(w http.ResponseWriter, r *http.Request) {
395+
if r.Method != "POST" {
396+
t.Errorf("Expected POST, got %v", r.Method)
397+
return
398+
}
399+
400+
body, _ := ioutil.ReadAll(r.Body)
401+
402+
// Verify request serialization
403+
var req AITestRequest
404+
if err := json.Unmarshal(body, &req); err != nil {
405+
t.Errorf("Failed to unmarshal request: %v", err)
406+
return
407+
}
408+
if req.Title != "Test login flow" || req.Prompt != "Log in with valid credentials" || req.StartURI != "/login" {
409+
t.Errorf("Unexpected request fields: %+v", req)
410+
}
411+
412+
json.NewEncoder(w).Encode(AITestResponse{TestID: 12345, Title: req.Title, State: "enabled"})
413+
})
414+
415+
response, err := client.CreateTestWithAI(&AITestRequest{
416+
Title: "Test login flow", Type: "test", StartURI: "/login", Prompt: "Log in with valid credentials",
417+
Browsers: []string{"windows11_chrome"},
418+
})
419+
420+
if err != nil || response.TestID != 12345 {
421+
t.Errorf("Unexpected result: %+v, err: %v", response, err)
422+
}
423+
}
424+
425+
func TestCreateTestWithAIValidation(t *testing.T) {
426+
setup()
427+
defer cleanup()
428+
429+
validBrowser := []string{"windows11_chrome"}
430+
431+
testCases := []struct {
432+
name string
433+
request *AITestRequest
434+
expectedErr string
435+
}{
436+
{
437+
name: "multiple browsers",
438+
request: &AITestRequest{
439+
Title: "Test", Type: "test", StartURI: "/", Prompt: "Test prompt",
440+
Browsers: []string{"windows11_chrome", "windows10_chrome"},
441+
},
442+
expectedErr: "only supports one browser",
443+
},
444+
{
445+
name: "invalid type",
446+
request: &AITestRequest{
447+
Title: "Test", Type: "snippet", StartURI: "/", Prompt: "Test prompt", Browsers: validBrowser,
448+
},
449+
expectedErr: "Type must be 'test'",
450+
},
451+
{
452+
name: "missing prompt",
453+
request: &AITestRequest{
454+
Title: "Test", Type: "test", StartURI: "/", Prompt: "", Browsers: validBrowser,
455+
},
456+
expectedErr: "Prompt is required",
457+
},
458+
{
459+
name: "mutually exclusive credentials",
460+
request: &AITestRequest{
461+
Title: "Test", Type: "test", StartURI: "/", Prompt: "Test prompt",
462+
PromptCredentials: "user / pass", LoginSnippetID: 12345, Browsers: validBrowser,
463+
},
464+
expectedErr: "Cannot specify both",
465+
},
466+
{
467+
name: "missing both StartURI and FullURL",
468+
request: &AITestRequest{
469+
Title: "Test", Type: "test", Prompt: "Test prompt", Browsers: validBrowser,
470+
},
471+
expectedErr: "Either StartURI or FullURL must be provided",
472+
},
473+
{
474+
name: "mutually exclusive StartURI and FullURL",
475+
request: &AITestRequest{
476+
Title: "Test", Type: "test", StartURI: "/", FullURL: "https://example.com",
477+
Prompt: "Test prompt", Browsers: validBrowser,
478+
},
479+
expectedErr: "Only one of StartURI or FullURL",
480+
},
481+
}
482+
483+
for _, tc := range testCases {
484+
t.Run(tc.name, func(t *testing.T) {
485+
_, err := client.CreateTestWithAI(tc.request)
486+
if err == nil {
487+
t.Errorf("Expected error for %s", tc.name)
488+
} else if !strings.Contains(err.Error(), tc.expectedErr) {
489+
t.Errorf("Expected error containing %q, got: %v", tc.expectedErr, err.Error())
490+
}
491+
})
492+
}
493+
}
494+
390495
func TestUpdateTest(t *testing.T) {
391496
// Test just the required attributes
392497
rfTest := RFTest{

0 commit comments

Comments
 (0)