Skip to content

Commit 85c11a2

Browse files
chore: add e2e tests (#118)
Co-authored-by: Danielle Maywood <[email protected]>
1 parent 6663ede commit 85c11a2

File tree

7 files changed

+509
-0
lines changed

7 files changed

+509
-0
lines changed

e2e/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# End-to-End Testing Framework
2+
3+
This directory contains the end-to-end (E2E) testing framework for the AgentAPI project. The framework simulates realistic agent interactions using a script-based approach with JSON configuration files.
4+
5+
## TL;DR
6+
7+
```shell
8+
go test ./e2e
9+
```
10+
11+
## How it Works
12+
13+
The testing framework (`echo_test.go`) does the following:
14+
- Reads a file in `testdata/`.
15+
- Starts the AgentAPI server with a fake agent (`echo.go`). This fake agent reads the scripted conversation from the specified JSON file.
16+
- The testing framework then sends messages to the fake agent.
17+
- The fake agent validates the expected messages and sends predefined responses.
18+
- The testing framework validates the actual responses against expected outcomes.
19+
20+
## Adding a new test
21+
22+
1. Create a new JSON file in `testdata/` with a unique name.
23+
2. Define the scripted conversation in the JSON file. Each message must have the following fields:
24+
- `expectMessage`: The message from the user that the fake agent expects.
25+
- `thinkDurationMS`: How long the fake agent should 'think' before responding.
26+
- `responseMessage`: The message the fake agent should respond with.
27+
3. Add a new test case in `echo_test.go` that references the newly created JSON file.
28+
> Be sure that the name of the test case exactly matches the name of the JSON file.
29+
4. Run the E2E tests to verify the new test case.

e2e/echo.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"os/signal"
10+
"regexp"
11+
"strings"
12+
"time"
13+
14+
"github.com/acarl005/stripansi"
15+
st "github.com/coder/agentapi/lib/screentracker"
16+
)
17+
18+
type ScriptEntry struct {
19+
ExpectMessage string `json:"expectMessage"`
20+
ThinkDurationMS int64 `json:"thinkDurationMS"`
21+
ResponseMessage string `json:"responseMessage"`
22+
}
23+
24+
func main() {
25+
if len(os.Args) != 2 {
26+
fmt.Println("Usage: echo <script.json>")
27+
os.Exit(1)
28+
}
29+
30+
runEchoAgent(os.Args[1])
31+
}
32+
33+
func loadScript(scriptPath string) ([]ScriptEntry, error) {
34+
data, err := os.ReadFile(scriptPath)
35+
if err != nil {
36+
return nil, fmt.Errorf("failed to read script file: %w", err)
37+
}
38+
39+
var script []ScriptEntry
40+
if err := json.Unmarshal(data, &script); err != nil {
41+
return nil, fmt.Errorf("failed to parse script JSON: %w", err)
42+
}
43+
44+
return script, nil
45+
}
46+
47+
func runEchoAgent(scriptPath string) {
48+
script, err := loadScript(scriptPath)
49+
if err != nil {
50+
fmt.Printf("Error loading script: %v\n", err)
51+
os.Exit(1)
52+
}
53+
54+
if len(script) == 0 {
55+
fmt.Println("Script is empty")
56+
os.Exit(1)
57+
}
58+
59+
ctx, cancel := context.WithCancel(context.Background())
60+
defer cancel()
61+
sigCh := make(chan os.Signal, 1)
62+
signal.Notify(sigCh, os.Interrupt)
63+
go func() {
64+
for {
65+
select {
66+
case <-sigCh:
67+
cancel()
68+
fmt.Println("Exiting...")
69+
os.Exit(0)
70+
case <-ctx.Done():
71+
return
72+
}
73+
}
74+
}()
75+
76+
var messages []st.ConversationMessage
77+
redrawTerminal(messages, false)
78+
79+
scriptIndex := 0
80+
scanner := bufio.NewScanner(os.Stdin)
81+
82+
for scriptIndex < len(script) {
83+
entry := script[scriptIndex]
84+
expectedMsg := strings.TrimSpace(entry.ExpectMessage)
85+
86+
// Handle initial/follow-up messages (empty ExpectMessage)
87+
if expectedMsg == "" {
88+
// Show thinking state if there's a delay
89+
if entry.ThinkDurationMS > 0 {
90+
redrawTerminal(messages, true)
91+
spinnerCtx, spinnerCancel := context.WithCancel(ctx)
92+
go runSpinner(spinnerCtx)
93+
time.Sleep(time.Duration(entry.ThinkDurationMS) * time.Millisecond)
94+
if spinnerCancel != nil {
95+
spinnerCancel()
96+
}
97+
}
98+
99+
messages = append(messages, st.ConversationMessage{
100+
Role: st.ConversationRoleAgent,
101+
Message: entry.ResponseMessage,
102+
Time: time.Now(),
103+
})
104+
redrawTerminal(messages, false)
105+
scriptIndex++
106+
continue
107+
}
108+
109+
// Wait for user input for non-initial messages
110+
if !scanner.Scan() {
111+
break
112+
}
113+
114+
input := scanner.Text()
115+
input = cleanTerminalInput(input)
116+
if input == "" {
117+
continue
118+
}
119+
120+
if input != expectedMsg {
121+
fmt.Printf("Error: Expected message '%s' but received '%s'\n", expectedMsg, input)
122+
os.Exit(1)
123+
}
124+
125+
messages = append(messages, st.ConversationMessage{
126+
Role: st.ConversationRoleUser,
127+
Message: entry.ExpectMessage,
128+
Time: time.Now(),
129+
})
130+
redrawTerminal(messages, false)
131+
132+
// Show thinking state if there's a delay
133+
if entry.ThinkDurationMS > 0 {
134+
redrawTerminal(messages, true)
135+
spinnerCtx, spinnerCancel := context.WithCancel(ctx)
136+
go runSpinner(spinnerCtx)
137+
time.Sleep(time.Duration(entry.ThinkDurationMS) * time.Millisecond)
138+
spinnerCancel()
139+
}
140+
141+
messages = append(messages, st.ConversationMessage{
142+
Role: st.ConversationRoleAgent,
143+
Message: entry.ResponseMessage,
144+
Time: time.Now(),
145+
})
146+
redrawTerminal(messages, false)
147+
scriptIndex++
148+
}
149+
150+
// Now just do nothing.
151+
<-make(chan struct{})
152+
}
153+
154+
func redrawTerminal(messages []st.ConversationMessage, thinking bool) {
155+
fmt.Print("\033[2J\033[H") // Clear screen and move cursor to home
156+
157+
// Show conversation history
158+
for _, msg := range messages {
159+
if msg.Role == st.ConversationRoleUser {
160+
fmt.Printf("> %s\n", msg.Message)
161+
} else {
162+
fmt.Printf("%s\n", msg.Message)
163+
}
164+
}
165+
166+
if thinking {
167+
fmt.Print("Thinking... ")
168+
} else {
169+
fmt.Print("> ")
170+
}
171+
}
172+
173+
func cleanTerminalInput(input string) string {
174+
// Strip ANSI escape sequences
175+
input = stripansi.Strip(input)
176+
177+
// Remove bracketed paste mode sequences (^[[200~ and ^[[201~)
178+
bracketedPasteRe := regexp.MustCompile(`\x1b\[\d+~`)
179+
input = bracketedPasteRe.ReplaceAllString(input, "")
180+
181+
// Remove backspace sequences (character followed by ^H)
182+
backspaceRe := regexp.MustCompile(`.\x08`)
183+
input = backspaceRe.ReplaceAllString(input, "")
184+
185+
// Remove other common control characters
186+
input = strings.ReplaceAll(input, "\x08", "") // backspace
187+
input = strings.ReplaceAll(input, "\x7f", "") // delete
188+
input = strings.ReplaceAll(input, "\x1b", "") // escape (if any remain)
189+
190+
return strings.TrimSpace(input)
191+
}
192+
193+
func runSpinner(ctx context.Context) {
194+
spinnerChars := []string{"|", "/", "-", "\\"}
195+
ticker := time.NewTicker(200 * time.Millisecond)
196+
defer ticker.Stop()
197+
i := 0
198+
199+
for {
200+
select {
201+
case <-ticker.C:
202+
fmt.Printf("\rThinking %s", spinnerChars[i%len(spinnerChars)])
203+
i++
204+
case <-ctx.Done():
205+
// Clear spinner on cancellation
206+
fmt.Print("\r" + strings.Repeat(" ", 20) + "\r")
207+
return
208+
}
209+
}
210+
}

0 commit comments

Comments
 (0)