Files
patterm/internal/harness/runner.go

244 lines
6.1 KiB
Go

package harness
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
type Event struct {
Step int `json:"step"`
Type string `json:"type"`
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Artifact string `json:"artifact_dir,omitempty"`
}
type RunResult struct {
OK bool `json:"ok"`
Steps int `json:"steps"`
Artifact string `json:"artifact_dir,omitempty"`
Error string `json:"error,omitempty"`
Events []Event `json:"-"`
}
func RunScenario(opts Options, emit func(Event)) (RunResult, error) {
sc := opts.Scenario
s, err := NewCLI(opts)
if err != nil {
return RunResult{OK: false, Error: err.Error()}, err
}
defer s.Close()
results := map[string]json.RawMessage{}
for i, step := range sc.Steps {
ev := Event{Step: i + 1, Type: step.Type}
err := runStep(s, step, results)
if err != nil {
ev.OK = false
ev.Error = err.Error()
art, aerr := s.DumpArtifacts(sc, i, err)
if aerr == nil {
ev.Artifact = art.Dir
}
if emit != nil {
emit(ev)
}
return RunResult{OK: false, Steps: i + 1, Error: err.Error(), Artifact: ev.Artifact}, err
}
ev.OK = true
if step.SaveAs != "" {
ev.Result = results[step.SaveAs]
}
if emit != nil {
emit(ev)
}
}
return RunResult{OK: true, Steps: len(sc.Steps)}, nil
}
func runStep(s *Session, step Step, results map[string]json.RawMessage) error {
switch step.Type {
case "wait_stable":
return s.WaitForStable(timeoutMS(step.TimeoutMS))
case "send_chord":
return s.SendChord(step.Chord)
case "send_text":
return s.SendText(step.Text)
case "assert_contains":
screen, err := s.Screen()
if err != nil {
return err
}
if !strings.Contains(screen, step.Contains) {
return fmt.Errorf("screen does not contain %q:\n%s", step.Contains, screen)
}
return nil
case "assert_not_contains":
screen, err := s.Screen()
if err != nil {
return err
}
if strings.Contains(screen, step.Contains) {
return fmt.Errorf("screen contains %q:\n%s", step.Contains, screen)
}
return nil
case "mark_raw":
if step.SaveAs == "" {
return fmt.Errorf("mark_raw requires save_as")
}
raw, err := json.Marshal(s.RawOffset())
if err != nil {
return err
}
results[step.SaveAs] = raw
return nil
case "assert_raw_since_regex":
raw, ok := results[step.From]
if !ok {
return fmt.Errorf("no saved result %q", step.From)
}
var offset int
if err := json.Unmarshal(raw, &offset); err != nil {
return fmt.Errorf("saved result %q is not a raw offset: %w", step.From, err)
}
re, err := regexp.Compile(step.Regex)
if err != nil {
return err
}
b := s.RawSince(offset)
if !re.Match(b) {
return fmt.Errorf("raw output since %q does not match %q:\n%s", step.From, step.Regex, string(b))
}
return nil
case "assert_regex":
return s.WaitForRegex(step.Regex, timeoutMS(step.TimeoutMS))
case "wait_text":
return s.WaitForText(step.Contains, timeoutMS(step.TimeoutMS))
case "assert_cursor":
cur, err := s.Cursor()
if err != nil {
return err
}
if step.CursorRow != nil && int(cur.Row) != *step.CursorRow {
return fmt.Errorf("cursor row = %d, want %d", cur.Row, *step.CursorRow)
}
if step.CursorCol != nil && int(cur.Col) != *step.CursorCol {
return fmt.Errorf("cursor col = %d, want %d", cur.Col, *step.CursorCol)
}
return nil
case "mcp_call":
params, err := resolveParams(step.Params, results)
if err != nil {
return err
}
raw, err := s.MCPCall(step.Method, params)
if err != nil {
return err
}
if step.SaveAs != "" {
results[step.SaveAs] = raw
}
return nil
case "assert_mcp":
params, perr := resolveParams(step.Params, results)
if perr != nil {
return perr
}
raw, err := s.MCPCall(step.Method, params)
if step.ErrorKind != "" {
if err == nil {
return fmt.Errorf("expected MCP error kind %q, got success %s", step.ErrorKind, string(raw))
}
rpcErr, ok := err.(*RPCError)
if !ok {
return err
}
if rpcErr.Data != nil {
if data, ok := rpcErr.Data.(map[string]any); ok && data["kind"] == step.ErrorKind {
return nil
}
}
if strings.Contains(rpcErr.Message, step.ErrorKind) || strings.Contains(rpcErr.Message, strings.ReplaceAll(step.ErrorKind, "_", " ")) {
return nil
}
return fmt.Errorf("MCP error = %#v, want kind %q", rpcErr, step.ErrorKind)
}
if err != nil {
return err
}
return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring)
case "assert_saved":
raw, ok := results[step.From]
if !ok {
return fmt.Errorf("no saved result %q", step.From)
}
return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring)
}
return fmt.Errorf("unknown step type %q", step.Type)
}
func resolveParams(raw json.RawMessage, saved map[string]json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return raw, nil
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
resolved, err := resolveValue(v, saved)
if err != nil {
return nil, err
}
return json.Marshal(resolved)
}
func resolveValue(v any, saved map[string]json.RawMessage) (any, error) {
switch x := v.(type) {
case string:
if strings.HasPrefix(x, "{{") && strings.HasSuffix(x, "}}") {
ref := strings.TrimSuffix(strings.TrimPrefix(x, "{{"), "}}")
parts := strings.SplitN(ref, ".", 2)
raw, ok := saved[parts[0]]
if !ok {
return nil, fmt.Errorf("unknown saved result %q", parts[0])
}
var sv any
if err := json.Unmarshal(raw, &sv); err != nil {
return nil, err
}
path := ""
if len(parts) == 2 {
path = parts[1]
}
got, ok := valueAt(sv, path)
if !ok {
return nil, fmt.Errorf("path %q not found in saved result %q", path, parts[0])
}
return got, nil
}
return x, nil
case []any:
for i := range x {
r, err := resolveValue(x[i], saved)
if err != nil {
return nil, err
}
x[i] = r
}
return x, nil
case map[string]any:
for k := range x {
r, err := resolveValue(x[k], saved)
if err != nil {
return nil, err
}
x[k] = r
}
return x, nil
default:
return x, nil
}
}