package testrunner import ( "context" "encoding/json" "fmt" "html/template" "log" "net" "net/http" "os" "path/filepath" "sort" "strings" "sync" "time" ) // Console serves a web UI for interactive scenario execution. type Console struct { Port int Token string ScenarioDir string Registry *Registry Log *log.Logger coordinator *Coordinator mu sync.Mutex running bool currentRun *runState results map[string]*ScenarioResult server *http.Server listener net.Listener } type runState struct { ScenarioName string `json:"scenario"` StartedAt time.Time `json:"started_at"` Status string `json:"status"` // "running", "done", "failed" Result *ScenarioResult } // ConsoleConfig holds configuration for creating a Console. type ConsoleConfig struct { Port int Token string ScenarioDir string Registry *Registry Logger *log.Logger } // NewConsole creates a new Console server. func NewConsole(cfg ConsoleConfig) *Console { logger := cfg.Logger if logger == nil { logger = log.New(os.Stderr, "[console] ", log.LstdFlags) } // Create an internal coordinator for agent management. coord := NewCoordinator(CoordinatorConfig{ Port: cfg.Port + 1, // agents register on port+1 Token: cfg.Token, Expected: make(map[string]string), // no expected agents by default Logger: log.New(os.Stderr, "[coord] ", log.LstdFlags), }) return &Console{ Port: cfg.Port, Token: cfg.Token, ScenarioDir: cfg.ScenarioDir, Registry: cfg.Registry, Log: logger, coordinator: coord, results: make(map[string]*ScenarioResult), } } // Start begins serving the console web UI. Blocks until stopped. func (c *Console) Start(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("/", c.handleIndex) mux.HandleFunc("/api/scenarios", c.handleScenarios) mux.HandleFunc("/api/run", c.handleRun) mux.HandleFunc("/api/status", c.handleStatus) mux.HandleFunc("/api/result/", c.handleResult) mux.HandleFunc("/api/report/", c.handleReport) mux.HandleFunc("/api/agents", c.handleAgents) mux.HandleFunc("/api/tiers", c.handleTiers) mux.HandleFunc("/register", c.coordinator.handleRegister) addr := fmt.Sprintf(":%d", c.Port) ln, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("listen %s: %w", addr, err) } c.mu.Lock() c.listener = ln c.server = &http.Server{Handler: mux} c.mu.Unlock() c.Log.Printf("console listening on http://localhost:%d", c.Port) c.Log.Printf("scenarios dir: %s", c.ScenarioDir) go func() { <-ctx.Done() c.Stop() }() if err := c.server.Serve(ln); err != nil && err != http.ErrServerClosed { return err } return nil } // Stop gracefully shuts down the console server. func (c *Console) Stop() { c.mu.Lock() srv := c.server c.mu.Unlock() if srv != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() srv.Shutdown(ctx) } } // ListenAddr returns the address the console is listening on. func (c *Console) ListenAddr() string { c.mu.Lock() defer c.mu.Unlock() if c.listener != nil { return c.listener.Addr().String() } return "" } // --- API Handlers --- type scenarioInfo struct { Name string `json:"name"` File string `json:"file"` Phases int `json:"phases"` } // GET /api/scenarios func (c *Console) handleScenarios(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } files, err := filepath.Glob(filepath.Join(c.ScenarioDir, "*.yaml")) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } var scenarios []scenarioInfo for _, f := range files { s, err := ParseFile(f) if err != nil { continue // skip invalid files } scenarios = append(scenarios, scenarioInfo{ Name: s.Name, File: filepath.Base(f), Phases: len(s.Phases), }) } sort.Slice(scenarios, func(i, j int) bool { return scenarios[i].File < scenarios[j].File }) writeJSON(w, http.StatusOK, scenarios) } type runRequest struct { Scenario string `json:"scenario"` } // POST /api/run func (c *Console) handleRun(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req runRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("decode: %v", err)}) return } if req.Scenario == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scenario field required"}) return } c.mu.Lock() if c.running { c.mu.Unlock() writeJSON(w, http.StatusConflict, map[string]string{"error": "a scenario is already running"}) return } c.running = true c.currentRun = &runState{ ScenarioName: req.Scenario, StartedAt: time.Now(), Status: "running", } c.mu.Unlock() // Parse and validate. scenarioPath := filepath.Join(c.ScenarioDir, req.Scenario) scenario, err := ParseFile(scenarioPath) if err != nil { c.mu.Lock() c.running = false c.currentRun = nil c.mu.Unlock() writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("parse: %v", err)}) return } // Launch in background. go c.executeScenario(scenario, req.Scenario) writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "scenario": req.Scenario}) } func (c *Console) executeScenario(scenario *Scenario, fileName string) { ctx := context.Background() if scenario.Timeout.Duration > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, scenario.Timeout.Duration) defer cancel() } logFunc := func(format string, args ...interface{}) { c.Log.Printf(format, args...) } engine := NewEngine(c.Registry, logFunc) actx := &ActionContext{ Scenario: scenario, Nodes: make(map[string]NodeRunner), Targets: make(map[string]TargetRunner), Vars: make(map[string]string), Log: logFunc, } result := engine.Run(ctx, scenario, actx) name := strings.TrimSuffix(fileName, ".yaml") c.mu.Lock() c.results[name] = result if c.currentRun != nil { c.currentRun.Status = "done" if result.Status == StatusFail { c.currentRun.Status = "failed" } c.currentRun.Result = result } c.running = false c.mu.Unlock() c.Log.Printf("scenario %s completed: %s (%s)", fileName, result.Status, result.Duration) } type statusResponse struct { Running bool `json:"running"` Scenario string `json:"scenario,omitempty"` Status string `json:"status,omitempty"` Elapsed string `json:"elapsed,omitempty"` } // GET /api/status func (c *Console) handleStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } c.mu.Lock() defer c.mu.Unlock() resp := statusResponse{Running: c.running} if c.currentRun != nil { resp.Scenario = c.currentRun.ScenarioName resp.Status = c.currentRun.Status resp.Elapsed = time.Since(c.currentRun.StartedAt).Round(time.Second).String() } writeJSON(w, http.StatusOK, resp) } // GET /api/result/{name} func (c *Console) handleResult(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } name := strings.TrimPrefix(r.URL.Path, "/api/result/") if name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"}) return } c.mu.Lock() result, ok := c.results[name] c.mu.Unlock() if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no result for " + name}) return } writeJSON(w, http.StatusOK, result) } // GET /api/report/{name} func (c *Console) handleReport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } name := strings.TrimPrefix(r.URL.Path, "/api/report/") if name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"}) return } c.mu.Lock() result, ok := c.results[name] c.mu.Unlock() if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no result for " + name}) return } // Render HTML report inline. data := buildHTMLData(result) tmpl, err := template.New("report").Parse(htmlTemplate) if err != nil { http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, data) } type agentInfo struct { Name string `json:"name"` Addr string `json:"addr"` Healthy bool `json:"healthy"` } // GET /api/agents func (c *Console) handleAgents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } c.coordinator.mu.Lock() var agents []agentInfo for name, info := range c.coordinator.agents { agents = append(agents, agentInfo{ Name: name, Addr: info.Addr, Healthy: info.Healthy, }) } c.coordinator.mu.Unlock() sort.Slice(agents, func(i, j int) bool { return agents[i].Name < agents[j].Name }) writeJSON(w, http.StatusOK, agents) } // GET /api/tiers func (c *Console) handleTiers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } writeJSON(w, http.StatusOK, c.Registry.ListByTier()) } // GET / func (c *Console) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(consoleSPA)) } // consoleSPA is the embedded single-page application. var consoleSPA = strings.TrimSpace(` sw-test-runner Console

sw-test-runner

Console
Select a scenario and click Run
`)