package harness import ( "encoding/json" "fmt" "regexp" "strings" "time" ) 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) case "wait_until_mcp": // Poll an MCP method until the assertion at Path holds (or // Contains substring matches), or TimeoutMS elapses. Used by the // idle-detection scenarios to wait for a child's idle_state to // reach a target value without sprinkling sleeps. params, perr := resolveParams(step.Params, results) if perr != nil { return perr } deadline := time.Now().Add(timeoutMS(step.TimeoutMS)) var lastRaw json.RawMessage var lastErr error for { raw, err := s.MCPCall(step.Method, params) if err == nil { if aerr := assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring); aerr == nil { if step.SaveAs != "" { results[step.SaveAs] = raw } return nil } else { lastErr = aerr lastRaw = raw } } else { lastErr = err } if time.Now().After(deadline) { if lastErr != nil { return fmt.Errorf("wait_until_mcp timeout: %w (last response: %s)", lastErr, string(lastRaw)) } return fmt.Errorf("wait_until_mcp timeout (no successful call)") } time.Sleep(100 * time.Millisecond) } } 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 } }