Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Rainforest CLI Changelog

## 3.8.0 - 2026-02-10
- Add `generate` command for AI test generation
- (478f5968aa4291c090852b073202ae1f68d60ab8, @sebaherrera07)

## 3.7.0 - 2026-02-09
- Update Go from 1.22.3 to 1.25.7
- (e0dc14e8c0c327daf6884a0e69e93482d156adae, @sebaherrera07)
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,46 @@ Download specific tests based on their id on the Rainforest dashboard
rainforest download 33445 11232 1337
```

#### Generating Tests with AI

Generate a new test using AI based on a natural language prompt.

```bash
rainforest generate "Log in with valid credentials and verify the dashboard loads" --start-uri /login
```

You can also use a full URL instead of a start URI.

```bash
rainforest generate "Add an item to the shopping cart" --url https://example.com/shop
```

Specify a custom title for the generated test.

```bash
rainforest generate "Complete the checkout process" --start-uri /cart --title "Checkout Flow Test"
```

Specify the platform for AI test generation. Note: AI test generation only supports one platform at a time, and the CLI accepts a single value for `--platform`. Commonly used platforms for AI generation include: `windows10_chrome`, `windows11_chrome`, and `windows11_chrome_fhd`, but other platforms may be available; unsupported values will be rejected by the Rainforest API.

```bash
rainforest generate "Test responsive layout" --start-uri / --platform windows11_chrome_fhd
```

Provide credentials information for the AI to use during test generation. This is a free-form string passed to the AI model.

```bash
rainforest generate "Log in as admin user" --start-uri /admin --credentials "username: admin, password: secret123"
```

Alternatively, use a login snippet for authentication.

```bash
rainforest generate "Verify dashboard features" --start-uri /dashboard --login-snippet-id 54321
```

Note: `--credentials` and `--login-snippet-id` are mutually exclusive.

#### Running Local RFML Tests Only

If you want to run a local set of RFML files (for instance in a CI environment), use the `run -f` option:
Expand Down
45 changes: 44 additions & 1 deletion rainforest-cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

const (
// Version of the app in SemVer
version = "3.7.0"
version = "3.8.0"
// This is the default spec folder for RFML tests
defaultSpecFolder = "./spec/rainforest"
)
Expand Down Expand Up @@ -359,6 +359,49 @@ func main() {
return newRFMLTest(c)
},
},
{
Name: "generate",
Aliases: []string{"gen", "ai"},
Usage: "Generate a new test using AI",
OnUsageError: onCommandUsageErrorHandler("generate"),
ArgsUsage: "[prompt]",
Description: "Generate a new Rainforest test using AI based on a natural language prompt. " +
"The prompt should describe what the test should do. " +
"Example: rainforest generate \"Log in with valid credentials and verify the dashboard loads\"",
Flags: []cli.Flag{
cli.StringFlag{
Name: "title",
Usage: "Custom title for the generated test.",
},
cli.StringFlag{
Name: "start-uri",
Usage: "The starting `URI` path for the test (e.g., /login).",
},
cli.StringFlag{
Name: "url",
Usage: "The full starting `URL` for the test (alternative to --start-uri).",
},
cli.StringFlag{
Name: "platform",
Usage: "Specify the `PLATFORM` to use for AI generation (e.g., windows10_chrome, windows11_chrome).",
},
cli.IntFlag{
Name: "environment-id",
Usage: "The `ENVIRONMENT-ID` to use for the test.",
},
cli.StringFlag{
Name: "credentials",
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.",
},
cli.IntFlag{
Name: "login-snippet-id",
Usage: "The `ID` of a snippet to use for login steps. Mutually exclusive with --credentials.",
},
},
Action: func(c *cli.Context) error {
return generateAITest(c, api)
},
},
{
Name: "validate",
Usage: "Validate your RFML tests",
Expand Down
69 changes: 69 additions & 0 deletions rainforest/tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,3 +533,72 @@ func (c *Client) UpdateTest(test *RFTest, branchID int) error {
}
return nil
}

// AITestRequest represents the parameters needed to create a test using AI generation
type AITestRequest struct {
Title string `json:"title,omitempty"`
Type string `json:"type"`
StartURI string `json:"start_uri,omitempty"`
FullURL string `json:"full_url,omitempty"`
Prompt string `json:"prompt"`
Browsers []string `json:"browsers,omitempty"`
EnvironmentID int `json:"environment_id,omitempty"`
PromptCredentials string `json:"prompt_credentials,omitempty"`
LoginSnippetID int `json:"login_snippet_id,omitempty"`
}

// AITestResponse represents the response from the AI test generation API
type AITestResponse struct {
TestID int `json:"id"`
RFMLID string `json:"rfml_id"`
Title string `json:"title"`
State string `json:"state"`
}

// CreateTestWithAI creates a new test using AI generation
func (c *Client) CreateTestWithAI(request *AITestRequest) (*AITestResponse, error) {
// AI test generation only supports one browser at a time
if len(request.Browsers) > 1 {
return nil, errors.New("AI test generation only supports one browser at a time")
}

// Ensure type is "test"
if request.Type != "test" {
return nil, errors.New("Type must be 'test' for AI test generation")
}

// Ensure prompt is provided
if request.Prompt == "" {
return nil, errors.New("Prompt is required for AI test generation")
}

// Ensure at least one of StartURI or FullURL is provided
if request.StartURI == "" && request.FullURL == "" {
return nil, errors.New("Either StartURI or FullURL must be provided for AI test generation")
}

// Ensure StartURI and FullURL are mutually exclusive
if request.StartURI != "" && request.FullURL != "" {
return nil, errors.New("Only one of StartURI or FullURL may be provided for AI test generation")
}

// Validate mutually exclusive login parameters
if request.PromptCredentials != "" && request.LoginSnippetID > 0 {
return nil, errors.New("Cannot specify both prompt_credentials and login_snippet_id. Choose one login method")
}

// Prepare request
req, err := c.NewRequest("POST", "tests", request)
if err != nil {
return nil, err
}

// Send request and process response
var response AITestResponse
_, err = c.Do(req, &response)
if err != nil {
return nil, err
}

return &response, nil
}
105 changes: 105 additions & 0 deletions rainforest/tests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,111 @@ func TestHasUploadableFiles(t *testing.T) {
}
}

func TestCreateTestWithAI(t *testing.T) {
setup()
defer cleanup()

mux.HandleFunc("/tests", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("Expected POST, got %v", r.Method)
return
}

body, _ := ioutil.ReadAll(r.Body)

// Verify request serialization
var req AITestRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to unmarshal request: %v", err)
return
}
if req.Title != "Test login flow" || req.Prompt != "Log in with valid credentials" || req.StartURI != "/login" {
t.Errorf("Unexpected request fields: %+v", req)
}

json.NewEncoder(w).Encode(AITestResponse{TestID: 12345, RFMLID: "ai-test", Title: req.Title, State: "enabled"})
})

response, err := client.CreateTestWithAI(&AITestRequest{
Title: "Test login flow", Type: "test", StartURI: "/login", Prompt: "Log in with valid credentials",
Browsers: []string{"windows11_chrome"},
})

if err != nil || response.TestID != 12345 || response.RFMLID != "ai-test" {
t.Errorf("Unexpected result: %+v, err: %v", response, err)
}
}

func TestCreateTestWithAIValidation(t *testing.T) {
setup()
defer cleanup()

validBrowser := []string{"windows11_chrome"}

testCases := []struct {
name string
request *AITestRequest
expectedErr string
}{
{
name: "multiple browsers",
request: &AITestRequest{
Title: "Test", Type: "test", StartURI: "/", Prompt: "Test prompt",
Browsers: []string{"windows11_chrome", "windows10_chrome"},
},
expectedErr: "only supports one browser",
},
{
name: "invalid type",
request: &AITestRequest{
Title: "Test", Type: "snippet", StartURI: "/", Prompt: "Test prompt", Browsers: validBrowser,
},
expectedErr: "Type must be 'test'",
},
{
name: "missing prompt",
request: &AITestRequest{
Title: "Test", Type: "test", StartURI: "/", Prompt: "", Browsers: validBrowser,
},
expectedErr: "Prompt is required",
},
{
name: "mutually exclusive credentials",
request: &AITestRequest{
Title: "Test", Type: "test", StartURI: "/", Prompt: "Test prompt",
PromptCredentials: "user / pass", LoginSnippetID: 12345, Browsers: validBrowser,
},
expectedErr: "Cannot specify both",
},
{
name: "missing both StartURI and FullURL",
request: &AITestRequest{
Title: "Test", Type: "test", Prompt: "Test prompt", Browsers: validBrowser,
},
expectedErr: "Either StartURI or FullURL must be provided",
},
{
name: "mutually exclusive StartURI and FullURL",
request: &AITestRequest{
Title: "Test", Type: "test", StartURI: "/", FullURL: "https://example.com",
Prompt: "Test prompt", Browsers: validBrowser,
},
expectedErr: "Only one of StartURI or FullURL",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := client.CreateTestWithAI(tc.request)
if err == nil {
t.Errorf("Expected error for %s", tc.name)
} else if !strings.Contains(err.Error(), tc.expectedErr) {
t.Errorf("Expected error containing %q, got: %v", tc.expectedErr, err.Error())
}
})
}
}

func TestUpdateTest(t *testing.T) {
// Test just the required attributes
rfTest := RFTest{
Expand Down
Loading