diff --git a/README.md b/README.md index c4b5011f..09b51d3f 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,87 @@ # Workflow Engine -A configurable workflow engine built on GoCodeAlone's Modular library v1.3.9, allowing applications to be built entirely from YAML configuration files. +A configurable, AI-powered workflow orchestration engine built on [CrisisTextLine/modular](https://github.com/CrisisTextLine/modular) v1.11.11, featuring a visual builder UI, dynamic component hot-reload, and comprehensive observability. ## Overview This workflow engine lets you create applications by chaining together modular components based on YAML configuration files. You can configure the same codebase to operate as: - An API server with authentication middleware -- An event processing system -- A bidirectional chat system with triage capabilities +- An event processing system with state machine workflows +- A message-driven pipeline with metrics and health monitoring +- An AI-assisted workflow builder with visual drag-and-drop UI All without changing code - just by modifying configuration files. +## Features + +### Visual Workflow Builder +- ReactFlow-based drag-and-drop UI +- 30 module types across 10 categories +- Real-time YAML import/export with round-trip fidelity +- Undo/redo, validation, and property editing + +### AI-Powered Generation +- **Anthropic Claude** direct API integration with tool use +- **GitHub Copilot SDK** integration for development workflows +- Automatic workflow generation from natural language descriptions +- Component suggestion and validation + +### Dynamic Component Hot-Reload +- Yaegi interpreter for runtime Go component loading +- File watcher for automatic hot-reload +- Sandboxed execution with stdlib-only imports +- Component registry with lifecycle management + +### EventBus Integration +- Native EventBus bridge for message broker compatibility +- Workflow lifecycle events (started, completed, failed) +- Event-driven triggers and subscriptions + +### Observability +- Prometheus metrics collection +- Health check endpoints (/health, /ready, /live) +- Request ID propagation (X-Request-ID) + ## Requirements -- Go 1.23 or later -- GoCodeAlone/modular v1.3.9 +- Go 1.25 or later +- Node.js 18+ (for UI development) -## Architecture +## Module Types -The workflow engine is built on these core concepts: +The engine supports 30 module types across 10 categories: -- **Modules**: Self-contained components that provide specific functionality -- **Workflows**: Configurations that chain modules together to create application behavior -- **Handlers**: Components that understand how to interpret and configure specific workflow types +| Category | Module Types | +|----------|-------------| +| HTTP | http.server, http.router, http.handler, http.proxy, api.handler, chimux.router | +| Middleware | http.middleware.auth, http.middleware.logging, http.middleware.ratelimit, http.middleware.cors, http.middleware.requestid | +| Messaging | messaging.broker, messaging.handler, messaging.broker.eventbus | +| State Machine | statemachine.engine, state.tracker, state.connector | +| Events | eventlogger.modular | +| Integration | httpclient.modular, data.transformer, webhook.sender | +| Scheduling | scheduler.modular | +| Infrastructure | auth.modular, eventbus.modular, cache.modular, database.modular, jsonschema.modular | +| Database | database.workflow | +| Observability | metrics.collector, health.checker | -### Module Types +## Quick Start -The engine supports several types of modules: +### Run the Server -- **HTTP Server**: Handles HTTP requests -- **HTTP Router**: Routes HTTP requests to handlers -- **HTTP Handlers**: Processes HTTP requests and generates responses -- **Authentication Middleware**: Validates requests before they reach handlers -- **Message Broker**: Facilitates message-based communication -- **Message Handlers**: Processes messages from specific topics +```bash +go run ./cmd/server -config example/order-processing-pipeline.yaml +``` -## Configuration +### Development UI + +```bash +cd ui && npm install && npm run dev +``` -Applications are defined via YAML configuration files. Here's a basic example: +### Configuration + +Applications are defined via YAML configuration files: ```yaml modules: @@ -59,69 +102,35 @@ workflows: handler: hello-handler ``` -### Advanced Configuration - -You can create more complex applications with authentication and messaging: - -```yaml -modules: - - name: api-http-server - type: http.server - config: - address: ":8080" - - name: api-router - type: http.router - - name: auth-middleware - type: http.middleware.auth - - name: users-api - type: api.handler - config: - resourceName: "users" - - name: message-broker - type: messaging.broker - -workflows: - http: - routes: - - method: GET - path: /api/users - handler: users-api - middlewares: - - auth-middleware - messaging: - subscriptions: - - topic: user-events - handler: user-event-handler -``` - ## Example Applications -The repository includes several example applications: - -1. **API Server**: A RESTful API with protected endpoints -2. **Event Processor**: A message-based event processing system -3. **SMS Chat**: A bidirectional chat system with triage workflow - -## Usage +The `example/` directory includes several configurations: -Run any example by specifying the configuration file: +- **Order Processing Pipeline**: 10-module workflow with HTTP, state machine, messaging, and observability +- **API Server**: RESTful API with protected endpoints +- **State Machine Workflow**: Order lifecycle with state transitions +- **Event Processor**: Message-based event processing +- **Data Pipeline**: Data transformation and webhook delivery -``` -go run example/main.go -config example/api-server-config.yaml -``` +## Testing -## Extending +```bash +# Go tests +go test ./... -To extend the workflow engine with custom modules: +# UI component tests +cd ui && npm test -1. Implement the appropriate interfaces in the `module` package -2. Register your module type in the `BuildFromConfig` method of the engine -3. Create a configuration file that uses your new module type +# E2E Playwright tests +cd ui && npx playwright test +``` -## Testing +## Architecture -Run the tests to verify the workflow engine functionality: +The engine is built on these core concepts: -``` -go test ./... -``` \ No newline at end of file +- **Modules**: Self-contained components providing specific functionality +- **Workflows**: YAML configurations chaining modules together +- **Handlers**: Components interpreting and configuring workflow types (HTTP, Messaging, State Machine, Scheduler, Integration) +- **Dynamic Components**: Runtime-loaded Go modules via Yaegi interpreter +- **AI Generation**: Natural language to workflow YAML via LLM APIs diff --git a/ROADMAP.md b/ROADMAP.md index 327cca16..32687225 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -91,14 +91,14 @@ A production-grade, AI-powered workflow orchestration engine with a visual build - [x] Integration verification with mock Copilot server ### E2E Test Expansion -- [ ] Update moduleTypeMap in all e2e specs with 6 new module types -- [ ] Update category count assertions (8 -> 10) -- [ ] New category visibility tests (Database, Observability) -- [ ] Drag-and-drop tests for new module types -- [ ] Property panel tests for new module config fields -- [ ] Complex workflow builder: multi-category, 5+ node workflows -- [ ] Screenshot-driven visual regression for all categories -- [ ] Keyboard shortcuts and accessibility testing +- [x] Update moduleTypeMap in all e2e specs with 6 new module types +- [x] Update category count assertions (8 -> 10) +- [x] New category visibility tests (Database, Observability) +- [x] Drag-and-drop tests for new module types +- [x] Property panel tests for new module config fields +- [x] Complex workflow builder: multi-category, 5+ node workflows +- [x] Screenshot-driven visual regression for all categories +- [x] Keyboard shortcuts and accessibility testing ### Handler Test Coverage (target: >70%) - [ ] IntegrationWorkflowHandler: database connector path @@ -139,38 +139,66 @@ A production-grade, AI-powered workflow orchestration engine with a visual build --- -## Phase 5: AI Server Bootstrap, Test Coverage & E2E Testing (In Progress) +## Phase 5: AI Server Bootstrap, Test Coverage & E2E Testing (Complete) +*PR #19 merged* ### AI Server Bootstrap (WS1) -- [ ] cmd/server/main.go with HTTP mux and AI handler registration -- [ ] CLI flags for config, address, AI provider configuration -- [ ] Graceful shutdown with signal handling -- [ ] initAIService with conditional Anthropic/Copilot provider registration -- [ ] cmd/server/main_test.go with route verification tests +- [x] cmd/server/main.go with HTTP mux and AI handler registration +- [x] CLI flags for config, address, AI provider configuration +- [x] Graceful shutdown with signal handling +- [x] initAIService with conditional Anthropic/Copilot provider registration +- [x] cmd/server/main_test.go with route verification tests ### Go Test Coverage (WS2) -- [ ] Root package (engine_test.go): 68.6% → ≥80% -- [ ] Module package: 77.1% → ≥80% -- [ ] Dynamic package: 75.4% → ≥80% -- [ ] AI packages: maintain ≥85% +- [x] Root package (engine_test.go): 68.6% → ≥80% +- [x] Module package: 77.1% → ≥80% +- [x] Dynamic package: 75.4% → ≥80% +- [x] AI packages: maintain ≥85% ### Playwright E2E Tests (WS3) -- [ ] Shared helpers (helpers.ts) with complete module type map -- [ ] deep-module-coverage.spec.ts: All 30 module types verified -- [ ] deep-complex-workflows.spec.ts: Multi-node workflow tests -- [ ] deep-property-editing.spec.ts: All field types tested -- [ ] deep-keyboard-shortcuts.spec.ts: Shortcut verification -- [ ] deep-ai-panel.spec.ts: AI Copilot panel tests -- [ ] deep-component-browser.spec.ts: Component Browser tests -- [ ] deep-import-export.spec.ts: Complex round-trip tests -- [ ] deep-edge-cases.spec.ts: Edge case coverage -- [ ] deep-accessibility.spec.ts: A11y testing -- [ ] deep-toast-notifications.spec.ts: Toast behavior tests -- [ ] deep-visual-regression.spec.ts: Visual regression baselines +- [x] Shared helpers (helpers.ts) with complete module type map +- [x] deep-module-coverage.spec.ts: All 30 module types verified +- [x] deep-complex-workflows.spec.ts: Multi-node workflow tests +- [x] deep-property-editing.spec.ts: All field types tested +- [x] deep-keyboard-shortcuts.spec.ts: Shortcut verification +- [x] deep-ai-panel.spec.ts: AI Copilot panel tests +- [x] deep-component-browser.spec.ts: Component Browser tests +- [x] deep-import-export.spec.ts: Complex round-trip tests +- [x] deep-edge-cases.spec.ts: Edge case coverage +- [x] deep-accessibility.spec.ts: A11y testing +- [x] deep-toast-notifications.spec.ts: Toast behavior tests +- [x] deep-visual-regression.spec.ts: Visual regression baselines --- -## Phase 6: Production Readiness (Planned) +## Phase 6: Integration, Realistic Workflows & Documentation (In Progress) + +### Bug Fixes +- [ ] Fix API path mismatch (/api/components → /api/dynamic/components) +- [ ] Fix stale moduleTypeMap in e2e specs (add EventBus Bridge) + +### Realistic Workflow Example +- [ ] Order Processing Pipeline (10 modules, 5 categories) +- [ ] Integration tests for end-to-end pipeline + +### Coverage Improvements +- [ ] cmd/server: 20.3% → ≥70% +- [ ] module: 78.2% → ≥80% + +### Copilot SDK Verification +- [ ] Tool handler integration tests +- [ ] Provider selection and fallback tests + +### Exploratory E2E Testing +- [ ] Phase 6 exploratory spec with ~33 tests and screenshots + +### Documentation +- [ ] README.md rewrite with current project state +- [ ] ROADMAP.md updates + +--- + +## Phase 7: Production Readiness (Planned) ### Workflow Execution Runtime - [ ] End-to-end workflow execution from YAML config @@ -201,11 +229,12 @@ A production-grade, AI-powered workflow orchestration engine with a visual build | Package | Current | Target | Status | |---------|---------|--------|--------| -| workflow (root) | 68.6% | 80% | Below target | -| ai | 84.8% | 85% | Near target | -| ai/copilot | 90.3% | 70% | ✓ Exceeded | -| ai/llm | 84.5% | 85% | Near target | +| workflow (root) | 97.0% | 80% | ✓ Exceeded | +| ai | 87.6% | 85% | ✓ Exceeded | +| ai/copilot | 90.7% | 70% | ✓ Exceeded | +| ai/llm | 91.2% | 85% | ✓ Exceeded | +| cmd/server | 20.3% | 70% | Below target | | config | 100% | 100% | ✓ Met | -| dynamic | 75.4% | 80% | Below target | +| dynamic | 85.5% | 80% | ✓ Exceeded | | handlers | 70.8% | 70% | ✓ Met | -| module | 77.1% | 80% | Below target | +| module | 78.2% | 80% | Below target | diff --git a/ai/copilot/integration_test.go b/ai/copilot/integration_test.go new file mode 100644 index 00000000..a3dfc948 --- /dev/null +++ b/ai/copilot/integration_test.go @@ -0,0 +1,331 @@ +package copilotai + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/ai" + "github.com/GoCodeAlone/workflow/config" + copilot "github.com/github/copilot-sdk/go" +) + +// TestCopilotIntegration_ToolHandlersInvocable invokes each tool handler +// directly with real inputs and verifies the outputs contain expected content. +func TestCopilotIntegration_ToolHandlersInvocable(t *testing.T) { + tools := workflowTools() + toolMap := make(map[string]copilot.Tool) + for _, tool := range tools { + toolMap[tool.Name] = tool + } + + t.Run("list_components", func(t *testing.T) { + tool, ok := toolMap["list_components"] + if !ok { + t.Fatal("list_components tool not found") + } + + result, err := tool.Handler(copilot.ToolInvocation{}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if result.ResultType != "success" { + t.Errorf("expected ResultType 'success', got %q", result.ResultType) + } + + var modules map[string]string + if err := json.Unmarshal([]byte(result.TextResultForLLM), &modules); err != nil { + t.Fatalf("result is not valid JSON map: %v", err) + } + + // Should contain many module types + expectedTypes := []string{ + "http.server", "http.router", "http.handler", + "messaging.broker", "messaging.handler", + "statemachine.engine", "event.processor", + "scheduler.modular", "cache.modular", + } + for _, mt := range expectedTypes { + if _, exists := modules[mt]; !exists { + t.Errorf("expected module type %q in list, not found", mt) + } + } + + if len(modules) < 10 { + t.Errorf("expected at least 10 module types, got %d", len(modules)) + } + }) + + t.Run("get_component_schema", func(t *testing.T) { + tool, ok := toolMap["get_component_schema"] + if !ok { + t.Fatal("get_component_schema tool not found") + } + + result, err := tool.Handler(copilot.ToolInvocation{ + Arguments: map[string]interface{}{"module_type": "http.server"}, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if result.ResultType != "success" { + t.Errorf("expected ResultType 'success', got %q", result.ResultType) + } + if !strings.Contains(result.TextResultForLLM, "address") { + t.Errorf("expected schema to contain 'address', got: %s", result.TextResultForLLM) + } + }) + + t.Run("validate_config_valid", func(t *testing.T) { + tool, ok := toolMap["validate_config"] + if !ok { + t.Fatal("validate_config tool not found") + } + + validYAML := `modules: + - name: httpServer + type: http.server + config: + address: ":8080" + - name: router + type: http.router + dependsOn: + - httpServer` + + result, err := tool.Handler(copilot.ToolInvocation{ + Arguments: map[string]interface{}{"config_yaml": validYAML}, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if result.ResultType != "success" { + t.Errorf("expected ResultType 'success', got %q", result.ResultType) + } + if !strings.Contains(result.TextResultForLLM, `"valid"`) { + t.Errorf("expected result to indicate valid config, got: %s", result.TextResultForLLM) + } + }) + + t.Run("validate_config_invalid", func(t *testing.T) { + tool, ok := toolMap["validate_config"] + if !ok { + t.Fatal("validate_config tool not found") + } + + result, err := tool.Handler(copilot.ToolInvocation{ + Arguments: map[string]interface{}{"config_yaml": "modules: []"}, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if !strings.Contains(result.TextResultForLLM, "no modules defined") { + t.Errorf("expected validation error about no modules, got: %s", result.TextResultForLLM) + } + }) + + t.Run("get_example_workflow", func(t *testing.T) { + tool, ok := toolMap["get_example_workflow"] + if !ok { + t.Fatal("get_example_workflow tool not found") + } + + categories := map[string]string{ + "http": "httpServer", + "messaging": "messageBroker", + "statemachine": "orderEngine", + "event": "eventProcessor", + "trigger": "workflows", + } + + for category, expectedContent := range categories { + t.Run(category, func(t *testing.T) { + result, err := tool.Handler(copilot.ToolInvocation{ + Arguments: map[string]interface{}{"category": category}, + }) + if err != nil { + t.Fatalf("handler returned error for category %q: %v", category, err) + } + if result.ResultType != "success" { + t.Errorf("expected ResultType 'success', got %q", result.ResultType) + } + if !strings.Contains(result.TextResultForLLM, expectedContent) { + t.Errorf("expected example for %q to contain %q, got: %s", + category, expectedContent, result.TextResultForLLM) + } + }) + } + }) +} + +// TestCopilotIntegration_ClientWithMockWrapper exercises all Client methods +// through a mock wrapper, verifying the full request-response flow. +func TestCopilotIntegration_ClientWithMockWrapper(t *testing.T) { + t.Run("GenerateWorkflow", func(t *testing.T) { + resp := ai.GenerateResponse{ + Workflow: &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "server", Type: "http.server", Config: map[string]interface{}{"address": ":8080"}}, + {Name: "router", Type: "http.router", DependsOn: []string{"server"}}, + }, + }, + Explanation: "HTTP server with router", + } + respJSON, _ := json.Marshal(resp) + wrapper := mockWrapperWithResponse(sessionEventWithContent(string(respJSON)), nil) + client := newTestClient(wrapper) + + result, err := client.GenerateWorkflow(context.Background(), ai.GenerateRequest{ + Intent: "create an HTTP server with routing", + Constraints: []string{"use port 8080"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Workflow == nil { + t.Fatal("expected non-nil workflow") + } + if len(result.Workflow.Modules) != 2 { + t.Fatalf("expected 2 modules, got %d", len(result.Workflow.Modules)) + } + if result.Explanation != "HTTP server with router" { + t.Errorf("unexpected explanation: %s", result.Explanation) + } + }) + + t.Run("GenerateComponent", func(t *testing.T) { + code := `package component + +import "fmt" + +func Name() string { return "custom-handler" } +func New() interface{} { return &Handler{} } + +type Handler struct{} +func (h *Handler) ServeHTTP() { fmt.Println("handling") }` + + text := "```go\n" + code + "\n```" + wrapper := mockWrapperWithResponse(sessionEventWithContent(text), nil) + client := newTestClient(wrapper) + + result, err := client.GenerateComponent(context.Background(), ai.ComponentSpec{ + Name: "custom-handler", + Type: "custom.handler", + Description: "A custom HTTP handler", + Interface: "modular.Module", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "func Name()") { + t.Errorf("expected generated code to contain Name(), got: %s", result) + } + if !strings.Contains(result, "custom-handler") { + t.Errorf("expected generated code to contain component name") + } + }) + + t.Run("SuggestWorkflow", func(t *testing.T) { + suggestions := []ai.WorkflowSuggestion{ + {Name: "REST API", Description: "Full REST API", Confidence: 0.95}, + {Name: "WebSocket Server", Description: "Real-time server", Confidence: 0.8}, + {Name: "Message Queue", Description: "Async processing", Confidence: 0.6}, + } + respJSON, _ := json.Marshal(suggestions) + wrapper := mockWrapperWithResponse(sessionEventWithContent(string(respJSON)), nil) + client := newTestClient(wrapper) + + result, err := client.SuggestWorkflow(context.Background(), "build a web application") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 3 { + t.Fatalf("expected 3 suggestions, got %d", len(result)) + } + if result[0].Name != "REST API" { + t.Errorf("expected first suggestion 'REST API', got %q", result[0].Name) + } + if result[0].Confidence != 0.95 { + t.Errorf("expected confidence 0.95, got %f", result[0].Confidence) + } + }) + + t.Run("IdentifyMissingComponents", func(t *testing.T) { + specs := []ai.ComponentSpec{ + {Name: "priceChecker", Type: "stock.price.checker", Description: "Checks prices", Interface: "modular.Module"}, + {Name: "tradeExecutor", Type: "trade.executor", Description: "Executes trades", Interface: "modular.Module"}, + } + specsJSON, _ := json.Marshal(specs) + wrapper := mockWrapperWithResponse(sessionEventWithContent(string(specsJSON)), nil) + client := newTestClient(wrapper) + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "server", Type: "http.server"}, + {Name: "checker", Type: "stock.price.checker"}, + {Name: "executor", Type: "trade.executor"}, + }, + } + + result, err := client.IdentifyMissingComponents(context.Background(), cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 2 { + t.Fatalf("expected 2 missing components, got %d", len(result)) + } + + names := make(map[string]bool) + for _, s := range result { + names[s.Name] = true + } + if !names["priceChecker"] { + t.Error("expected priceChecker in missing components") + } + if !names["tradeExecutor"] { + t.Error("expected tradeExecutor in missing components") + } + }) +} + +// TestCopilotIntegration_ProviderRegistered verifies that a Copilot client +// can be registered as a provider in the AI service and appears in the +// provider list. +func TestCopilotIntegration_ProviderRegistered(t *testing.T) { + svc := ai.NewService() + + // Create a copilot client with a mock wrapper + resp := ai.GenerateResponse{ + Workflow: &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "test", Type: "http.server"}, + }, + }, + Explanation: "test", + } + respJSON, _ := json.Marshal(resp) + wrapper := mockWrapperWithResponse(sessionEventWithContent(string(respJSON)), nil) + client := newTestClient(wrapper) + + // Register the copilot client as a provider + svc.RegisterGenerator(ai.ProviderCopilot, client) + + providers := svc.Providers() + if len(providers) != 1 { + t.Fatalf("expected 1 provider, got %d", len(providers)) + } + if providers[0] != ai.ProviderCopilot { + t.Errorf("expected provider %q, got %q", ai.ProviderCopilot, providers[0]) + } + + // Verify we can generate through the service + result, err := svc.GenerateWorkflow(context.Background(), ai.GenerateRequest{ + Intent: "test workflow", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Workflow == nil { + t.Fatal("expected non-nil workflow from service") + } +} diff --git a/ai/service_integration_test.go b/ai/service_integration_test.go new file mode 100644 index 00000000..aaeaede7 --- /dev/null +++ b/ai/service_integration_test.go @@ -0,0 +1,159 @@ +package ai + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/config" +) + +// TestService_MultipleProviders registers two mock generators with different +// provider names and verifies both appear in Providers(). +func TestService_MultipleProviders(t *testing.T) { + svc := NewService() + + mockAnthropic := &MockGenerator{} + mockCopilot := &MockGenerator{} + + svc.RegisterGenerator(ProviderAnthropic, mockAnthropic) + svc.RegisterGenerator(ProviderCopilot, mockCopilot) + + providers := svc.Providers() + if len(providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(providers)) + } + + found := make(map[Provider]bool) + for _, p := range providers { + found[p] = true + } + if !found[ProviderAnthropic] { + t.Error("expected anthropic provider to be registered") + } + if !found[ProviderCopilot] { + t.Error("expected copilot provider to be registered") + } + + // Auto-select should prefer anthropic when both are registered + svc.SetPreferred(ProviderAuto) + resp, err := svc.GenerateWorkflow(context.Background(), GenerateRequest{Intent: "test"}) + if err != nil { + t.Fatalf("unexpected error with auto-select: %v", err) + } + if resp == nil || resp.Workflow == nil { + t.Fatal("expected valid response from auto-selected provider") + } + + // Explicit preference should work + svc.SetPreferred(ProviderCopilot) + resp, err = svc.GenerateWorkflow(context.Background(), GenerateRequest{Intent: "test"}) + if err != nil { + t.Fatalf("unexpected error with explicit copilot: %v", err) + } + if resp == nil || resp.Workflow == nil { + t.Fatal("expected valid response from copilot provider") + } +} + +// TestService_GenerateWorkflow_MockProvider registers a mock generator, +// calls GenerateWorkflow through the service, and verifies the response. +func TestService_GenerateWorkflow_MockProvider(t *testing.T) { + svc := NewService() + + expectedModules := []config.ModuleConfig{ + {Name: "server", Type: "http.server", Config: map[string]interface{}{"address": ":8080"}}, + {Name: "router", Type: "http.router", DependsOn: []string{"server"}}, + {Name: "handler", Type: "http.handler", DependsOn: []string{"router"}}, + } + + mock := &MockGenerator{ + GenerateWorkflowFn: func(ctx context.Context, req GenerateRequest) (*GenerateResponse, error) { + return &GenerateResponse{ + Workflow: &config.WorkflowConfig{ + Modules: expectedModules, + }, + Explanation: "Generated for: " + req.Intent, + }, nil + }, + } + + svc.RegisterGenerator(ProviderAnthropic, mock) + + resp, err := svc.GenerateWorkflow(context.Background(), GenerateRequest{ + Intent: "Create an HTTP API", + Constraints: []string{"use port 8080"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Workflow == nil { + t.Fatal("expected non-nil workflow") + } + if len(resp.Workflow.Modules) != 3 { + t.Fatalf("expected 3 modules, got %d", len(resp.Workflow.Modules)) + } + if resp.Workflow.Modules[0].Name != "server" { + t.Errorf("expected first module 'server', got %q", resp.Workflow.Modules[0].Name) + } + if resp.Explanation != "Generated for: Create an HTTP API" { + t.Errorf("unexpected explanation: %s", resp.Explanation) + } +} + +// TestService_GenerateComponent_MockProvider registers a mock generator, +// calls GenerateComponent through the service, and verifies the output. +func TestService_GenerateComponent_MockProvider(t *testing.T) { + svc := NewService() + + mock := &MockGenerator{ + GenerateComponentFn: func(ctx context.Context, spec ComponentSpec) (string, error) { + return `package component + +import "fmt" + +func Name() string { return "` + spec.Name + `" } +func New() interface{} { return &Component{} } + +type Component struct{} +func (c *Component) Init() error { fmt.Println("init"); return nil } +`, nil + }, + } + + svc.RegisterGenerator(ProviderAnthropic, mock) + + code, err := svc.GenerateComponent(context.Background(), ComponentSpec{ + Name: "my-handler", + Type: "custom.handler", + Description: "A custom handler component", + Interface: "modular.Module", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code == "" { + t.Fatal("expected non-empty generated code") + } + if !contains(code, "my-handler") { + t.Errorf("expected code to contain component name 'my-handler'") + } + if !contains(code, "func Name()") { + t.Error("expected code to contain Name() function") + } + if !contains(code, "func New()") { + t.Error("expected code to contain New() function") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsStr(s, substr) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 7192f2c0..97829fa3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,28 +31,8 @@ var ( anthropicModel = flag.String("anthropic-model", "", "Anthropic model name") ) -func main() { - flag.Parse() - - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - AddSource: true, - Level: slog.LevelDebug, - })) - - // Load workflow config - var cfg *config.WorkflowConfig - if *configFile != "" { - var err error - cfg, err = config.LoadFromFile(*configFile) - if err != nil { - log.Fatalf("Failed to load configuration: %v", err) - } - } else { - cfg = config.NewEmptyWorkflowConfig() - logger.Info("No config file specified, using empty workflow config") - } - - // Create modular application and workflow engine +// buildEngine creates the workflow engine with all handlers registered and built from config. +func buildEngine(cfg *config.WorkflowConfig, logger *slog.Logger) (*workflow.StdEngine, *dynamic.Loader, *dynamic.ComponentRegistry, error) { app := modular.NewStdApplication(nil, logger) engine := workflow.NewStdEngine(app, logger) @@ -71,63 +51,126 @@ func main() { // Build engine from config if err := engine.BuildFromConfig(cfg); err != nil { - log.Fatalf("Failed to build workflow: %v", err) + return nil, nil, nil, fmt.Errorf("failed to build workflow: %w", err) } - // Initialize AI service - aiSvc, deploySvc := initAIService(logger, registry, pool) + return engine, loader, registry, nil +} - // Create HTTP mux and register all handlers +// buildMux creates the HTTP mux with all routes registered. +func buildMux(aiSvc *ai.Service, deploySvc *ai.DeployService, loader *dynamic.Loader, registry *dynamic.ComponentRegistry, cfg *config.WorkflowConfig) *http.ServeMux { mux := http.NewServeMux() - - // AI handlers ai.NewHandler(aiSvc).RegisterRoutes(mux) ai.NewDeployHandler(deploySvc).RegisterRoutes(mux) - - // TODO: UI api.ts calls /api/dynamic/components but Go handler registers at /api/components - // Dynamic component API dynamic.NewAPIHandler(loader, registry).RegisterRoutes(mux) - - // Workflow UI (static file catch-all MUST be last) module.NewWorkflowUIHandler(cfg).RegisterRoutes(mux) + return mux +} - // Create context for graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +// loadConfig loads a workflow configuration from the configured file path, +// or returns an empty config if no path is set. +func loadConfig(logger *slog.Logger) (*config.WorkflowConfig, error) { + if *configFile != "" { + cfg, err := config.LoadFromFile(*configFile) + if err != nil { + return nil, fmt.Errorf("failed to load configuration: %w", err) + } + return cfg, nil + } + logger.Info("No config file specified, using empty workflow config") + return config.NewEmptyWorkflowConfig(), nil +} + +// serverApp holds the components needed to run the server. +type serverApp struct { + engine *workflow.StdEngine + mux *http.ServeMux + logger *slog.Logger +} + +// setup initializes all server components: engine, AI services, and HTTP mux. +func setup(logger *slog.Logger, cfg *config.WorkflowConfig) (*serverApp, error) { + engine, loader, registry, err := buildEngine(cfg, logger) + if err != nil { + return nil, fmt.Errorf("failed to build engine: %w", err) + } + + pool := dynamic.NewInterpreterPool() + aiSvc, deploySvc := initAIService(logger, registry, pool) + mux := buildMux(aiSvc, deploySvc, loader, registry, cfg) + + return &serverApp{ + engine: engine, + mux: mux, + logger: logger, + }, nil +} +// run starts the engine and HTTP server, blocking until ctx is canceled. +// It performs graceful shutdown when the context is done. +func run(ctx context.Context, app *serverApp, listenAddr string) error { // Start the workflow engine - if err := engine.Start(ctx); err != nil { - log.Fatalf("Failed to start workflow engine: %v", err) + if err := app.engine.Start(ctx); err != nil { + return fmt.Errorf("failed to start workflow engine: %w", err) } - // Start HTTP server server := &http.Server{ - Addr: *addr, - Handler: mux, + Addr: listenAddr, + Handler: app.mux, } go func() { - logger.Info("Starting server", "addr", *addr) + app.logger.Info("Starting server", "addr", listenAddr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server failed: %v", err) + app.logger.Error("Server failed", "error", err) } }() - fmt.Printf("Workflow server started on %s\n", *addr) + // Wait for context cancellation + <-ctx.Done() - // Wait for termination signal - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh + if err := server.Shutdown(context.Background()); err != nil { + app.logger.Error("HTTP server shutdown error", "error", err) + } + if err := app.engine.Stop(context.Background()); err != nil { + app.logger.Error("Engine shutdown error", "error", err) + } - fmt.Println("Shutting down...") - cancel() + return nil +} - if err := server.Shutdown(context.Background()); err != nil { - log.Printf("HTTP server shutdown error: %v", err) +func main() { + flag.Parse() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + })) + + cfg, err := loadConfig(logger) + if err != nil { + log.Fatalf("Configuration error: %v", err) } - if err := engine.Stop(ctx); err != nil { - log.Printf("Engine shutdown error: %v", err) + + app, err := setup(logger, cfg) + if err != nil { + log.Fatalf("Setup error: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + // Wait for termination signal in a goroutine + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Println("Shutting down...") + cancel() + }() + + fmt.Printf("Workflow server started on %s\n", *addr) + if err := run(ctx, app, *addr); err != nil { + log.Fatalf("Server error: %v", err) } fmt.Println("Shutdown complete") diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 58628658..8df81d63 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/GoCodeAlone/workflow/ai" "github.com/GoCodeAlone/workflow/config" @@ -176,3 +177,435 @@ func TestEndToEnd_MockProvider(t *testing.T) { t.Error("expected at least one module in workflow") } } + +func TestBuildEngine_WithConfig(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "web-server", Type: "http.server", Config: map[string]interface{}{"address": ":9090"}}, + {Name: "web-router", Type: "http.router", Config: map[string]interface{}{"prefix": "/api"}, DependsOn: []string{"web-server"}}, + }, + Workflows: map[string]interface{}{}, + Triggers: map[string]interface{}{}, + } + + engine, loader, registry, err := buildEngine(cfg, logger) + if err != nil { + t.Fatalf("buildEngine failed: %v", err) + } + if engine == nil { + t.Fatal("expected non-nil engine") + } + if loader == nil { + t.Fatal("expected non-nil loader") + } + if registry == nil { + t.Fatal("expected non-nil registry") + } +} + +func TestBuildEngine_EmptyConfig(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := config.NewEmptyWorkflowConfig() + + engine, loader, registry, err := buildEngine(cfg, logger) + if err != nil { + t.Fatalf("buildEngine with empty config failed: %v", err) + } + if engine == nil { + t.Fatal("expected non-nil engine") + } + if loader == nil { + t.Fatal("expected non-nil loader") + } + if registry == nil { + t.Fatal("expected non-nil registry") + } +} + +func TestBuildMux_AllRoutesRegistered(t *testing.T) { + svc := ai.NewService() + svc.RegisterGenerator(ai.ProviderAnthropic, &mockGenerator{}) + + pool := dynamic.NewInterpreterPool() + registry := dynamic.NewComponentRegistry() + loader := dynamic.NewLoader(pool, registry) + deploy := ai.NewDeployService(svc, registry, pool) + cfg := config.NewEmptyWorkflowConfig() + + mux := buildMux(svc, deploy, loader, registry, cfg) + + tests := []struct { + name string + method string + path string + body interface{} + }{ + {"ai providers", http.MethodGet, "/api/ai/providers", nil}, + {"ai generate", http.MethodPost, "/api/ai/generate", ai.GenerateRequest{Intent: "test"}}, + {"ai suggest", http.MethodPost, "/api/ai/suggest", map[string]string{"useCase": "test"}}, + {"dynamic components", http.MethodGet, "/api/dynamic/components", nil}, + {"workflow config", http.MethodGet, "/api/workflow/config", nil}, + {"workflow validate", http.MethodPost, "/api/workflow/validate", map[string]interface{}{"modules": []interface{}{}}}, + {"workflow modules", http.MethodGet, "/api/workflow/modules", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req *http.Request + if tt.body != nil { + body, _ := json.Marshal(tt.body) + req = httptest.NewRequest(tt.method, tt.path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(tt.method, tt.path, nil) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code == http.StatusNotFound { + t.Errorf("route %s %s returned 404, expected a registered handler", tt.method, tt.path) + } + }) + } +} + +func TestBuildMux_StaticFilesFallback(t *testing.T) { + svc := ai.NewService() + pool := dynamic.NewInterpreterPool() + registry := dynamic.NewComponentRegistry() + loader := dynamic.NewLoader(pool, registry) + deploy := ai.NewDeployService(svc, registry, pool) + cfg := config.NewEmptyWorkflowConfig() + + mux := buildMux(svc, deploy, loader, registry, cfg) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code == http.StatusNotFound { + t.Error("GET / returned 404, expected static file server response") + } +} + +func TestInitAIService_CopilotFailure(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + pool := dynamic.NewInterpreterPool() + registry := dynamic.NewComponentRegistry() + + *anthropicKey = "" + // NewClient accepts any path without validation, so the provider registers + // successfully. The failure will occur later at generation time. + *copilotCLI = "/nonexistent/path/to/copilot-cli-binary" + defer func() { *copilotCLI = "" }() + + svc, deploy := initAIService(logger, registry, pool) + if svc == nil { + t.Fatal("expected non-nil service even with invalid copilot path") + } + if deploy == nil { + t.Fatal("expected non-nil deploy service even with invalid copilot path") + } + + // Copilot client is created successfully (path is validated at call time, not creation), + // so we should have 1 provider (copilot) registered. + providers := svc.Providers() + if len(providers) != 1 { + t.Errorf("expected 1 provider (copilot), got %d", len(providers)) + } +} + +func TestIntegration_GenerateEndpoint(t *testing.T) { + svc := ai.NewService() + svc.RegisterGenerator(ai.ProviderAnthropic, &mockGenerator{}) + + pool := dynamic.NewInterpreterPool() + registry := dynamic.NewComponentRegistry() + loader := dynamic.NewLoader(pool, registry) + deploy := ai.NewDeployService(svc, registry, pool) + cfg := config.NewEmptyWorkflowConfig() + + mux := buildMux(svc, deploy, loader, registry, cfg) + + body, _ := json.Marshal(ai.GenerateRequest{Intent: "Create a REST API"}) + req := httptest.NewRequest(http.MethodPost, "/api/ai/generate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp ai.GenerateResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Workflow == nil { + t.Error("expected workflow in response") + } + if resp.Explanation == "" { + t.Error("expected non-empty explanation") + } +} + +func TestLoadConfig_NoFile(t *testing.T) { + *configFile = "" + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + cfg, err := loadConfig(logger) + if err != nil { + t.Fatalf("loadConfig failed: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if len(cfg.Modules) != 0 { + t.Errorf("expected empty modules, got %d", len(cfg.Modules)) + } +} + +func TestLoadConfig_InvalidFile(t *testing.T) { + *configFile = "/nonexistent/config.yaml" + defer func() { *configFile = "" }() + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + _, err := loadConfig(logger) + if err == nil { + t.Fatal("expected error for nonexistent config file") + } +} + +func TestLoadConfig_ValidFile(t *testing.T) { + // Create a temp YAML config file + tmpFile, err := os.CreateTemp("", "workflow-test-*.yaml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + yamlContent := `modules: + - name: test-server + type: http.server + config: + address: ":9999" +workflows: {} +triggers: {} +` + if _, err := tmpFile.WriteString(yamlContent); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + _ = tmpFile.Close() + + *configFile = tmpFile.Name() + defer func() { *configFile = "" }() + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + cfg, err := loadConfig(logger) + if err != nil { + t.Fatalf("loadConfig failed: %v", err) + } + if len(cfg.Modules) != 1 { + t.Fatalf("expected 1 module, got %d", len(cfg.Modules)) + } + if cfg.Modules[0].Name != "test-server" { + t.Errorf("expected module name test-server, got %s", cfg.Modules[0].Name) + } +} + +func TestSetup_EmptyConfig(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + *anthropicKey = "" + *copilotCLI = "" + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := config.NewEmptyWorkflowConfig() + + app, err := setup(logger, cfg) + if err != nil { + t.Fatalf("setup failed: %v", err) + } + if app == nil { + t.Fatal("expected non-nil serverApp") + } + if app.engine == nil { + t.Fatal("expected non-nil engine") + } + if app.mux == nil { + t.Fatal("expected non-nil mux") + } + if app.logger == nil { + t.Fatal("expected non-nil logger") + } +} + +func TestRun_ImmediateCancel(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + *anthropicKey = "" + *copilotCLI = "" + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := config.NewEmptyWorkflowConfig() + + app, err := setup(logger, cfg) + if err != nil { + t.Fatalf("setup failed: %v", err) + } + + // Create a context and cancel it immediately so run() exits quickly + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err = run(ctx, app, ":0") + if err != nil { + t.Fatalf("run failed: %v", err) + } +} + +func TestRun_ServerStartsAndStops(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + *anthropicKey = "" + *copilotCLI = "" + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := config.NewEmptyWorkflowConfig() + + app, err := setup(logger, cfg) + if err != nil { + t.Fatalf("setup failed: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + done <- run(ctx, app, ":0") + }() + + // Give the server a moment to start + time.Sleep(50 * time.Millisecond) + + // Cancel and wait for shutdown + cancel() + if err := <-done; err != nil { + t.Fatalf("run failed: %v", err) + } +} + +func TestInitAIService_BothProviders(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "test-key-both") + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + pool := dynamic.NewInterpreterPool() + registry := dynamic.NewComponentRegistry() + + *anthropicKey = "" + *copilotCLI = "/some/copilot/path" + defer func() { *copilotCLI = "" }() + + svc, deploy := initAIService(logger, registry, pool) + if svc == nil { + t.Fatal("expected non-nil service") + } + if deploy == nil { + t.Fatal("expected non-nil deploy service") + } + + providers := svc.Providers() + if len(providers) != 2 { + t.Errorf("expected 2 providers, got %d", len(providers)) + } +} + +func TestInitAIService_AnthropicViaFlag(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + pool := dynamic.NewInterpreterPool() + registry := dynamic.NewComponentRegistry() + + *anthropicKey = "flag-key-123" + *copilotCLI = "" + defer func() { *anthropicKey = "" }() + + svc, _ := initAIService(logger, registry, pool) + + providers := svc.Providers() + if len(providers) != 1 { + t.Fatalf("expected 1 provider, got %d", len(providers)) + } + if providers[0] != ai.ProviderAnthropic { + t.Errorf("expected anthropic provider, got %s", providers[0]) + } +} + +func TestSetup_EngineError(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + *anthropicKey = "" + *copilotCLI = "" + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "bad", Type: "nonexistent.type"}, + }, + Workflows: map[string]interface{}{}, + Triggers: map[string]interface{}{}, + } + + _, err := setup(logger, cfg) + if err == nil { + t.Fatal("expected error for invalid module type in setup") + } +} + +func TestBuildEngine_InvalidModuleType(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "bad-module", Type: "nonexistent.type.that.does.not.exist"}, + }, + Workflows: map[string]interface{}{}, + Triggers: map[string]interface{}{}, + } + + _, _, _, err := buildEngine(cfg, logger) + if err == nil { + t.Fatal("expected error for invalid module type") + } +} + +func TestSetup_WithModules(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "test-key") + *anthropicKey = "" + *copilotCLI = "" + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "srv", Type: "http.server", Config: map[string]interface{}{"address": ":7070"}}, + }, + Workflows: map[string]interface{}{}, + Triggers: map[string]interface{}{}, + } + + app, err := setup(logger, cfg) + if err != nil { + t.Fatalf("setup failed: %v", err) + } + if app == nil { + t.Fatal("expected non-nil serverApp") + } + + // Verify the mux has routes by testing one + req := httptest.NewRequest(http.MethodGet, "/api/ai/providers", nil) + w := httptest.NewRecorder() + app.mux.ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Error("expected /api/ai/providers to be registered") + } +} diff --git a/dynamic/api.go b/dynamic/api.go index 4e943844..c0d037aa 100644 --- a/dynamic/api.go +++ b/dynamic/api.go @@ -29,8 +29,8 @@ type loadComponentRequest struct { // RegisterRoutes registers the dynamic component API routes on the given mux. func (h *APIHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("/api/components", h.handleComponents) - mux.HandleFunc("/api/components/", h.handleComponentByID) + mux.HandleFunc("/api/dynamic/components", h.handleComponents) + mux.HandleFunc("/api/dynamic/components/", h.handleComponentByID) } func (h *APIHandler) handleComponents(w http.ResponseWriter, r *http.Request) { @@ -45,8 +45,8 @@ func (h *APIHandler) handleComponents(w http.ResponseWriter, r *http.Request) { } func (h *APIHandler) handleComponentByID(w http.ResponseWriter, r *http.Request) { - // Extract ID from path: /api/components/{id} - id := strings.TrimPrefix(r.URL.Path, "/api/components/") + // Extract ID from path: /api/dynamic/components/{id} + id := strings.TrimPrefix(r.URL.Path, "/api/dynamic/components/") if id == "" { http.Error(w, "component id required", http.StatusBadRequest) return diff --git a/dynamic/api_test.go b/dynamic/api_test.go index 1eafcf61..8d0e2f6d 100644 --- a/dynamic/api_test.go +++ b/dynamic/api_test.go @@ -22,7 +22,7 @@ func TestAPI_ComponentsMethodNotAllowed(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodDelete, "/api/components", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/dynamic/components", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -40,8 +40,8 @@ func TestAPI_ComponentByID_EmptyID(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - // Requesting /api/components/ with no ID suffix - req := httptest.NewRequest(http.MethodGet, "/api/components/", nil) + // Requesting /api/dynamic/components/ with no ID suffix + req := httptest.NewRequest(http.MethodGet, "/api/dynamic/components/", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -59,7 +59,7 @@ func TestAPI_ComponentByID_MethodNotAllowed(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodPatch, "/api/components/some-id", nil) + req := httptest.NewRequest(http.MethodPatch, "/api/dynamic/components/some-id", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -77,7 +77,7 @@ func TestAPI_CreateComponent_InvalidJSON(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodPost, "/api/components", strings.NewReader("not json")) + req := httptest.NewRequest(http.MethodPost, "/api/dynamic/components", strings.NewReader("not json")) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -97,7 +97,7 @@ func TestAPI_CreateComponent_MissingFields(t *testing.T) { // Missing source body := `{"id":"test"}` - req := httptest.NewRequest(http.MethodPost, "/api/components", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/dynamic/components", strings.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -107,7 +107,7 @@ func TestAPI_CreateComponent_MissingFields(t *testing.T) { // Missing id body = `{"source":"package component"}` - req = httptest.NewRequest(http.MethodPost, "/api/components", strings.NewReader(body)) + req = httptest.NewRequest(http.MethodPost, "/api/dynamic/components", strings.NewReader(body)) w = httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -125,7 +125,7 @@ func TestAPI_UpdateComponent_InvalidJSON(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodPut, "/api/components/test", strings.NewReader("not json")) + req := httptest.NewRequest(http.MethodPut, "/api/dynamic/components/test", strings.NewReader("not json")) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -144,7 +144,7 @@ func TestAPI_UpdateComponent_EmptySource(t *testing.T) { api.RegisterRoutes(mux) body := `{"source":""}` - req := httptest.NewRequest(http.MethodPut, "/api/components/test", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPut, "/api/dynamic/components/test", strings.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -164,7 +164,7 @@ func TestAPI_UpdateComponent_ReloadError(t *testing.T) { // Try to reload with invalid Go source body := `{"source":"this is not valid go source"}` - req := httptest.NewRequest(http.MethodPut, "/api/components/nonexistent", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPut, "/api/dynamic/components/nonexistent", strings.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -182,7 +182,7 @@ func TestAPI_DeleteComponent_NotFound(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodDelete, "/api/components/nonexistent", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/dynamic/components/nonexistent", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -217,7 +217,7 @@ func TestAPI_DeleteComponent_RunningComponent(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodDelete, "/api/components/running", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/dynamic/components/running", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -389,12 +389,12 @@ func TestAPI_RegisterRoutes_Patterns(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - // GET /api/components should work (200 with empty list) - req := httptest.NewRequest(http.MethodGet, "/api/components", nil) + // GET /api/dynamic/components should work (200 with empty list) + req := httptest.NewRequest(http.MethodGet, "/api/dynamic/components", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { - t.Errorf("expected 200 for GET /api/components, got %d", w.Code) + t.Errorf("expected 200 for GET /api/dynamic/components, got %d", w.Code) } var infos []ComponentInfo diff --git a/dynamic/dynamic_test.go b/dynamic/dynamic_test.go index 3f282ef8..2864f457 100644 --- a/dynamic/dynamic_test.go +++ b/dynamic/dynamic_test.go @@ -498,7 +498,7 @@ func TestAPI_ListComponents(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodGet, "/api/components", nil) + req := httptest.NewRequest(http.MethodGet, "/api/dynamic/components", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -529,7 +529,7 @@ func TestAPI_CreateComponent(t *testing.T) { "source": minimalComponentSource, } bodyBytes, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/api/components", strings.NewReader(string(bodyBytes))) + req := httptest.NewRequest(http.MethodPost, "/api/dynamic/components", strings.NewReader(string(bodyBytes))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -553,7 +553,7 @@ func TestAPI_CreateComponent_BadSource(t *testing.T) { api.RegisterRoutes(mux) body := `{"id":"bad","source":"this is not valid go"}` - req := httptest.NewRequest(http.MethodPost, "/api/components", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/dynamic/components", strings.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -572,7 +572,7 @@ func TestAPI_GetComponent(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodGet, "/api/components/mycomp", nil) + req := httptest.NewRequest(http.MethodGet, "/api/dynamic/components/mycomp", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -590,7 +590,7 @@ func TestAPI_GetComponent_NotFound(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodGet, "/api/components/nope", nil) + req := httptest.NewRequest(http.MethodGet, "/api/dynamic/components/nope", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -609,7 +609,7 @@ func TestAPI_DeleteComponent(t *testing.T) { mux := http.NewServeMux() api.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodDelete, "/api/components/del", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/dynamic/components/del", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -635,7 +635,7 @@ func TestAPI_UpdateComponent(t *testing.T) { newSource := strings.Replace(simpleComponentSource, "test-component", "updated-component", 1) payload := map[string]string{"source": newSource} bodyBytes, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPut, "/api/components/upd", strings.NewReader(string(bodyBytes))) + req := httptest.NewRequest(http.MethodPut, "/api/dynamic/components/upd", strings.NewReader(string(bodyBytes))) w := httptest.NewRecorder() mux.ServeHTTP(w, req) diff --git a/example/order-processing-pipeline.yaml b/example/order-processing-pipeline.yaml new file mode 100644 index 00000000..5a8b63d7 --- /dev/null +++ b/example/order-processing-pipeline.yaml @@ -0,0 +1,129 @@ +# Order Processing Pipeline Example Configuration +# A 10-component pipeline demonstrating HTTP, processing, state machine, +# messaging, and observability modules working together. +# +# Data flow: +# HTTP POST /api/orders -> order-server -> order-router -> order-api +# -> order-transformer -> order-state-engine -> order-state-tracker +# -> order-broker -> notification-handler +# with order-metrics and order-health providing observability + +modules: + # --- HTTP layer --- + - name: order-server + type: http.server + config: + address: ":8080" + + - name: order-router + type: http.router + dependsOn: + - order-server + + - name: order-api + type: http.handler + dependsOn: + - order-router + config: + method: "POST" + path: "/api/orders" + contentType: "application/json" + + # --- Processing layer --- + - name: order-transformer + type: data.transformer + config: + description: "Transforms and validates incoming order data" + + # --- State machine layer --- + - name: order-state-engine + type: statemachine.engine + config: + description: "Manages order lifecycle state transitions" + + - name: order-state-tracker + type: state.tracker + config: + description: "Tracks current state for all order resources" + + # --- Messaging layer --- + - name: order-broker + type: messaging.broker + config: + description: "In-memory message broker for order events" + + - name: notification-handler + type: messaging.handler + config: + topic: "order.completed" + description: "Handles notifications when orders reach terminal states" + + # --- Observability layer --- + - name: order-metrics + type: metrics.collector + config: + description: "Collects Prometheus metrics for the order pipeline" + + - name: order-health + type: health.checker + config: + description: "Provides health, readiness, and liveness endpoints" + +workflows: + # HTTP routing + http: + router: order-router + server: order-server + routes: + - method: "POST" + path: "/api/orders" + handler: order-api + + # State machine definitions + statemachine: + engine: order-state-engine + definitions: + - name: order-processing + description: "Order processing workflow" + initialState: "received" + states: + received: + description: "Order has been received" + isFinal: false + isError: false + validated: + description: "Order has been validated" + isFinal: false + isError: false + stored: + description: "Order has been persisted" + isFinal: false + isError: false + notified: + description: "Notification has been sent" + isFinal: true + isError: false + failed: + description: "Order processing failed" + isFinal: true + isError: true + transitions: + validate_order: + fromState: "received" + toState: "validated" + store_order: + fromState: "validated" + toState: "stored" + send_notification: + fromState: "stored" + toState: "notified" + fail_validation: + fromState: "received" + toState: "failed" + + # Messaging subscriptions + messaging: + broker: order-broker + subscriptions: + - topic: "order.completed" + handler: notification-handler diff --git a/module/api_handlers_test.go b/module/api_handlers_test.go index e05ed5b7..b2841fe5 100644 --- a/module/api_handlers_test.go +++ b/module/api_handlers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -505,6 +506,107 @@ func TestRESTAPIHandler_HandleTransition_WithStateMachineEngine(t *testing.T) { } } +func TestRESTAPIHandler_HandleGet_WithStateTracker(t *testing.T) { + h := setupHandler(t) + + // Register a state tracker service in the app + tracker := NewStateTracker("workflow.service.statetracker") + if err := h.app.RegisterService(StateTrackerName, tracker); err != nil { + t.Fatalf("failed to register state tracker: %v", err) + } + + // Create a resource + createBody := `{"id": "order-1", "product": "widget"}` + createReq := httptest.NewRequest(http.MethodPost, "/api/orders", bytes.NewBufferString(createBody)) + createW := httptest.NewRecorder() + h.Handle(createW, createReq) + + // Set state for the resource + tracker.SetState("orders", "order-1", "processing", map[string]interface{}{ + "priority": "high", + }) + + // Get the resource - should be enhanced with state info + req := httptest.NewRequest(http.MethodGet, "/api/orders/{id}", nil) + req.SetPathValue("id", "order-1") + w := httptest.NewRecorder() + + h.Handle(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var resource RESTResource + if err := json.NewDecoder(w.Body).Decode(&resource); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resource.State != "processing" { + t.Errorf("expected state 'processing', got '%s'", resource.State) + } + if resource.Data["priority"] != "high" { + t.Errorf("expected priority 'high' from state tracker, got %v", resource.Data["priority"]) + } +} + +func TestRESTAPIHandler_HandlePut_MissingID(t *testing.T) { + h := setupHandler(t) + + body := `{"product": "widget"}` + req := httptest.NewRequest(http.MethodPut, "/api/orders", bytes.NewBufferString(body)) + w := httptest.NewRecorder() + + // Directly call handlePut with empty ID + h.handlePut("", w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestRESTAPIHandler_HandleDelete_MissingID(t *testing.T) { + h := setupHandler(t) + + req := httptest.NewRequest(http.MethodDelete, "/api/orders", nil) + w := httptest.NewRecorder() + + // Directly call handleDelete with empty ID + h.handleDelete("", w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestRESTAPIHandler_HandleGet_ResourceWithModules(t *testing.T) { + h := setupHandler(t) + + // Create resources + for i, id := range []string{"a", "b", "c"} { + body := `{"id": "` + id + `", "order": ` + fmt.Sprintf("%d", i+1) + `}` + req := httptest.NewRequest(http.MethodPost, "/api/orders", bytes.NewBufferString(body)) + w := httptest.NewRecorder() + h.Handle(w, req) + } + + // List all resources (handleGet with empty ID path) + req := httptest.NewRequest(http.MethodGet, "/api/orders", nil) + w := httptest.NewRecorder() + h.handleGet("", w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var resources []RESTResource + if err := json.NewDecoder(w.Body).Decode(&resources); err != nil { + t.Fatalf("failed to decode: %v", err) + } + if len(resources) != 3 { + t.Errorf("expected 3 resources, got %d", len(resources)) + } +} + func TestRESTAPIHandler_Constructor(t *testing.T) { h := NewRESTAPIHandler("test-handler", "orders") constructor := h.Constructor() diff --git a/module/event_processor_service_test.go b/module/event_processor_service_test.go new file mode 100644 index 00000000..b45d0ce8 --- /dev/null +++ b/module/event_processor_service_test.go @@ -0,0 +1,92 @@ +package module + +import ( + "testing" +) + +func TestNewEventProcessorLocator(t *testing.T) { + app := CreateIsolatedApp(t) + locator := NewEventProcessorLocator(app) + if locator.App == nil { + t.Fatal("expected App to be set") + } +} + +func TestEventProcessorLocator_Locate_Found(t *testing.T) { + app := CreateIsolatedApp(t) + ep := NewEventProcessor("eventProcessor") + if err := ep.Init(app); err != nil { + t.Fatalf("Init failed: %v", err) + } + + locator := NewEventProcessorLocator(app) + found, err := locator.Locate("eventProcessor") + if err != nil { + t.Fatalf("Locate failed: %v", err) + } + if found != ep { + t.Error("expected Locate to return the registered event processor") + } +} + +func TestEventProcessorLocator_Locate_NotFound(t *testing.T) { + app := CreateIsolatedApp(t) + locator := NewEventProcessorLocator(app) + + _, err := locator.Locate("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent processor") + } +} + +func TestEventProcessorLocator_LocateDefault(t *testing.T) { + app := CreateIsolatedApp(t) + ep := NewEventProcessor("eventProcessor") + if err := ep.Init(app); err != nil { + t.Fatalf("Init failed: %v", err) + } + + locator := NewEventProcessorLocator(app) + found, err := locator.LocateDefault() + if err != nil { + t.Fatalf("LocateDefault failed: %v", err) + } + if found != ep { + t.Error("expected LocateDefault to find the event processor") + } +} + +func TestEventProcessorLocator_LocateDefault_NotFound(t *testing.T) { + app := CreateIsolatedApp(t) + locator := NewEventProcessorLocator(app) + + _, err := locator.LocateDefault() + if err == nil { + t.Fatal("expected error when no default processor exists") + } +} + +func TestGetProcessor(t *testing.T) { + app := CreateIsolatedApp(t) + ep := NewEventProcessor("eventProcessor") + if err := ep.Init(app); err != nil { + t.Fatalf("Init failed: %v", err) + } + + found, err := GetProcessor(app) + if err != nil { + t.Fatalf("GetProcessor failed: %v", err) + } + if found != ep { + t.Error("expected GetProcessor to return the registered event processor") + } +} + +func TestGetProcessor_NotFound(t *testing.T) { + app := CreateIsolatedApp(t) + + _, err := GetProcessor(app) + if err == nil { + t.Fatal("expected error when no processor exists") + } +} diff --git a/module/event_processor_test.go b/module/event_processor_test.go index 07eaff86..323995e4 100644 --- a/module/event_processor_test.go +++ b/module/event_processor_test.go @@ -412,6 +412,35 @@ func TestEventProcessor_CleanupOldEvents(t *testing.T) { } } +func TestEventProcessor_Init(t *testing.T) { + ep := NewEventProcessor("test-ep") + app := CreateIsolatedApp(t) + + if err := ep.Init(app); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Verify appContext was stored + if ep.appContext == nil { + t.Fatal("expected appContext to be set after Init") + } + + // Verify service was registered + var svc interface{} + if err := app.GetService("test-ep", &svc); err != nil { + t.Fatalf("expected service to be registered: %v", err) + } +} + +func TestEventProcessor_Start(t *testing.T) { + ep := NewEventProcessor("test-processor") + + // Start launches a goroutine for cleanup; verify it doesn't error + if err := ep.Start(context.Background()); err != nil { + t.Fatalf("Start failed: %v", err) + } +} + func TestEventProcessor_Stop(t *testing.T) { ep := NewEventProcessor("test-processor") if err := ep.Stop(context.Background()); err != nil { diff --git a/module/health_test.go b/module/health_test.go index fd1e8f40..32331ff3 100644 --- a/module/health_test.go +++ b/module/health_test.go @@ -184,6 +184,14 @@ func TestHealthChecker_ProvidesServices(t *testing.T) { } } +func TestHealthChecker_RequiresServices(t *testing.T) { + h := NewHealthChecker("test-health") + deps := h.RequiresServices() + if deps != nil { + t.Errorf("expected nil dependencies, got %v", deps) + } +} + func TestHealthChecker_HealthHandler_NoChecks(t *testing.T) { h := NewHealthChecker("test-health") diff --git a/module/http_middleware_test.go b/module/http_middleware_test.go index 6a8d80cb..1d3f5458 100644 --- a/module/http_middleware_test.go +++ b/module/http_middleware_test.go @@ -1,6 +1,7 @@ package module import ( + "context" "net/http" "net/http/httptest" "testing" @@ -287,3 +288,43 @@ func TestCORSMiddleware_RequiresServices(t *testing.T) { t.Error("expected nil dependencies") } } + +func TestRateLimitMiddleware_Start(t *testing.T) { + m := NewRateLimitMiddleware("rl", 60, 10) + if err := m.Start(context.TODO()); err != nil { + t.Fatalf("Start failed: %v", err) + } +} + +func TestRateLimitMiddleware_Stop(t *testing.T) { + m := NewRateLimitMiddleware("rl", 60, 10) + if err := m.Stop(context.TODO()); err != nil { + t.Fatalf("Stop failed: %v", err) + } +} + +func TestCORSMiddleware_Start(t *testing.T) { + m := NewCORSMiddleware("cors", nil, nil) + if err := m.Start(context.TODO()); err != nil { + t.Fatalf("Start failed: %v", err) + } +} + +func TestCORSMiddleware_Stop(t *testing.T) { + m := NewCORSMiddleware("cors", nil, nil) + if err := m.Stop(context.TODO()); err != nil { + t.Fatalf("Stop failed: %v", err) + } +} + +func TestMin(t *testing.T) { + if min(3, 5) != 3 { + t.Error("expected min(3, 5) = 3") + } + if min(7, 2) != 2 { + t.Error("expected min(7, 2) = 2") + } + if min(4, 4) != 4 { + t.Error("expected min(4, 4) = 4") + } +} diff --git a/module/namespace_test.go b/module/namespace_test.go index da305f40..74ff9af5 100644 --- a/module/namespace_test.go +++ b/module/namespace_test.go @@ -211,6 +211,73 @@ func TestCustomNamespaceImplementation(t *testing.T) { } } +func TestStandardNamespace_ResolveDependency(t *testing.T) { + ns := NewStandardNamespace("dev", "") + result := ns.ResolveDependency("database") + if result != "dev-database" { + t.Errorf("expected 'dev-database', got %q", result) + } +} + +func TestModuleNamespace_ResolveDependency(t *testing.T) { + ns := NewModuleNamespace("prod", "") + result := ns.ResolveDependency("cache") + if result != "prod-cache" { + t.Errorf("expected 'prod-cache', got %q", result) + } +} + +func TestModuleNamespace_ResolveServiceName(t *testing.T) { + ns := NewModuleNamespace("prod", "") + result := ns.ResolveServiceName("auth") + if result != "prod-auth" { + t.Errorf("expected 'prod-auth', got %q", result) + } +} + +func TestValidatingNamespace_ResolveDependency(t *testing.T) { + stdNs := NewStandardNamespace("dev", "") + vn := WithValidation(stdNs) + result := vn.ResolveDependency("service") + if result != "dev-service" { + t.Errorf("expected 'dev-service', got %q", result) + } +} + +func TestValidatingNamespace_ResolveServiceName(t *testing.T) { + stdNs := NewStandardNamespace("dev", "") + vn := WithValidation(stdNs) + result := vn.ResolveServiceName("auth") + if result != "dev-auth" { + t.Errorf("expected 'dev-auth', got %q", result) + } +} + +func TestModuleNamespaceProviderFunc_Defaults(t *testing.T) { + // Test with nil functions - should use defaults + provider := ModuleNamespaceProviderFunc{} + + // FormatName with nil func returns baseName as-is + if result := provider.FormatName("test"); result != "test" { + t.Errorf("expected 'test', got %q", result) + } + + // ResolveDependency with nil func delegates to FormatName + if result := provider.ResolveDependency("dep"); result != "dep" { + t.Errorf("expected 'dep', got %q", result) + } + + // ResolveServiceName with nil func delegates to FormatName + if result := provider.ResolveServiceName("svc"); result != "svc" { + t.Errorf("expected 'svc', got %q", result) + } + + // ValidateModuleName with nil func returns nil + if err := provider.ValidateModuleName("anything"); err != nil { + t.Errorf("expected nil error, got %v", err) + } +} + // ScopeError represents a namespace error type ScopeError struct { message string diff --git a/order_processing_test.go b/order_processing_test.go new file mode 100644 index 00000000..1ba3cde2 --- /dev/null +++ b/order_processing_test.go @@ -0,0 +1,545 @@ +package workflow + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/handlers" + "github.com/GoCodeAlone/workflow/module" +) + +// TestOrderProcessingPipeline_BuildFromConfig loads the YAML config and +// verifies that all 10 modules are created and the engine builds without error. +func TestOrderProcessingPipeline_BuildFromConfig(t *testing.T) { + cfg, err := config.LoadFromFile("example/order-processing-pipeline.yaml") + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if len(cfg.Modules) != 10 { + t.Fatalf("Expected 10 modules in config, got %d", len(cfg.Modules)) + } + + // Verify expected module names + expectedNames := []string{ + "order-server", "order-router", "order-api", + "order-transformer", "order-state-engine", "order-state-tracker", + "order-broker", "notification-handler", + "order-metrics", "order-health", + } + for i, name := range expectedNames { + if cfg.Modules[i].Name != name { + t.Errorf("Module %d: expected name %q, got %q", i, name, cfg.Modules[i].Name) + } + } + + // Build engine from config using the real modular.Application so Init() + // properly initializes modules and registers their services. + logger := &mockLogger{} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + engine := NewStdEngine(app, logger) + + engine.RegisterWorkflowHandler(handlers.NewHTTPWorkflowHandler()) + engine.RegisterWorkflowHandler(handlers.NewStateMachineWorkflowHandler()) + engine.RegisterWorkflowHandler(handlers.NewMessagingWorkflowHandler()) + + err = engine.BuildFromConfig(cfg) + if err != nil { + t.Fatalf("BuildFromConfig failed: %v", err) + } + + // Verify all modules registered in the application + registeredModules := app.GetAllModules() + for _, name := range expectedNames { + if registeredModules[name] == nil { + t.Errorf("Module %q was not registered in the application", name) + } + } +} + +// TestOrderProcessingPipeline_EndToEnd exercises the full order processing +// pipeline: create a state machine definition, register a transformer pipeline, +// create a workflow instance, trigger transitions through received->validated->stored->notified, +// publish a message to the broker, and verify the notification handler receives it. +func TestOrderProcessingPipeline_EndToEnd(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + // Create individual modules directly for end-to-end testing + stateMachineEngine := module.NewStateMachineEngine("order-state-engine") + transformer := module.NewDataTransformer("order-transformer") + broker := module.NewInMemoryMessageBroker("order-broker") + tracker := module.NewStateTracker("order-state-tracker") + metrics := module.NewMetricsCollector("order-metrics") + health := module.NewHealthChecker("order-health") + + // Register modules with the app + app.RegisterModule(stateMachineEngine) + app.RegisterModule(transformer) + app.RegisterModule(broker) + app.RegisterModule(tracker) + app.RegisterModule(metrics) + app.RegisterModule(health) + + // Initialize modules so services get registered + if err := stateMachineEngine.Init(app); err != nil { + t.Fatalf("Failed to init state machine engine: %v", err) + } + if err := transformer.Init(app); err != nil { + t.Fatalf("Failed to init transformer: %v", err) + } + if err := broker.Init(app); err != nil { + t.Fatalf("Failed to init broker: %v", err) + } + if err := tracker.Init(app); err != nil { + t.Fatalf("Failed to init tracker: %v", err) + } + if err := metrics.Init(app); err != nil { + t.Fatalf("Failed to init metrics: %v", err) + } + if err := health.Init(app); err != nil { + t.Fatalf("Failed to init health: %v", err) + } + + // Register order-processing state machine definition + err := stateMachineEngine.RegisterDefinition(&module.StateMachineDefinition{ + Name: "order-processing", + Description: "Order processing workflow", + InitialState: "received", + States: map[string]*module.State{ + "received": {Name: "received", Description: "Order received"}, + "validated": {Name: "validated", Description: "Order validated"}, + "stored": {Name: "stored", Description: "Order stored"}, + "notified": {Name: "notified", Description: "Notification sent", IsFinal: true}, + "failed": {Name: "failed", Description: "Order failed", IsFinal: true, IsError: true}, + }, + Transitions: map[string]*module.Transition{ + "validate_order": {Name: "validate_order", FromState: "received", ToState: "validated"}, + "store_order": {Name: "store_order", FromState: "validated", ToState: "stored"}, + "send_notification": {Name: "send_notification", FromState: "stored", ToState: "notified"}, + "fail_validation": {Name: "fail_validation", FromState: "received", ToState: "failed"}, + }, + }) + if err != nil { + t.Fatalf("Failed to register definition: %v", err) + } + + // Register a transformer pipeline for order validation + transformer.RegisterPipeline(&module.TransformPipeline{ + Name: "validate-order", + Operations: []module.TransformOperation{ + { + Type: "filter", + Config: map[string]interface{}{"fields": []interface{}{"orderId", "customer", "total"}}, + }, + { + Type: "map", + Config: map[string]interface{}{"mappings": map[string]interface{}{"customer": "customerName"}}, + }, + }, + }) + + // Set up notification tracking via broker subscription + var mu sync.Mutex + var receivedMessages [][]byte + notificationHandler := module.NewFunctionMessageHandler(func(message []byte) error { + mu.Lock() + defer mu.Unlock() + receivedMessages = append(receivedMessages, message) + return nil + }) + if err := broker.Subscribe("order.completed", notificationHandler); err != nil { + t.Fatalf("Failed to subscribe: %v", err) + } + + ctx := context.Background() + + // Step 1: Transform the incoming order data + orderData := map[string]interface{}{ + "orderId": "ORD-001", + "customer": "Alice", + "total": 99.99, + "extra": "should-be-filtered", + } + + transformed, err := transformer.Transform(ctx, "validate-order", orderData) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + + transformedMap, ok := transformed.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result from transform, got %T", transformed) + } + + // Verify filter removed "extra" and map renamed "customer" to "customerName" + if _, exists := transformedMap["extra"]; exists { + t.Error("Expected 'extra' field to be filtered out") + } + if _, exists := transformedMap["customerName"]; !exists { + t.Error("Expected 'customer' to be renamed to 'customerName'") + } + + // Step 2: Create workflow instance and transition through states + instance, err := stateMachineEngine.CreateWorkflow("order-processing", "ORD-001", map[string]interface{}{ + "orderId": "ORD-001", + }) + if err != nil { + t.Fatalf("CreateWorkflow failed: %v", err) + } + if instance.CurrentState != "received" { + t.Fatalf("Expected initial state 'received', got %q", instance.CurrentState) + } + + // Track state in the state tracker + tracker.SetState("order", "ORD-001", "received", nil) + + // Transition: received -> validated + err = stateMachineEngine.TriggerTransition(ctx, "ORD-001", "validate_order", map[string]interface{}{ + "validated": true, + }) + if err != nil { + t.Fatalf("validate_order transition failed: %v", err) + } + tracker.SetState("order", "ORD-001", "validated", nil) + + // Transition: validated -> stored + err = stateMachineEngine.TriggerTransition(ctx, "ORD-001", "store_order", nil) + if err != nil { + t.Fatalf("store_order transition failed: %v", err) + } + tracker.SetState("order", "ORD-001", "stored", nil) + + // Transition: stored -> notified + err = stateMachineEngine.TriggerTransition(ctx, "ORD-001", "send_notification", nil) + if err != nil { + t.Fatalf("send_notification transition failed: %v", err) + } + tracker.SetState("order", "ORD-001", "notified", nil) + + // Verify final state + inst, err := stateMachineEngine.GetInstance("ORD-001") + if err != nil { + t.Fatalf("GetInstance failed: %v", err) + } + if inst.CurrentState != "notified" { + t.Errorf("Expected final state 'notified', got %q", inst.CurrentState) + } + if !inst.Completed { + t.Error("Expected workflow to be marked as completed") + } + + // Step 3: Publish a message to the broker and verify notification handler receives it + err = broker.SendMessage("order.completed", []byte(`{"orderId":"ORD-001","status":"notified"}`)) + if err != nil { + t.Fatalf("SendMessage failed: %v", err) + } + + mu.Lock() + msgCount := len(receivedMessages) + mu.Unlock() + if msgCount != 1 { + t.Errorf("Expected 1 message received by notification handler, got %d", msgCount) + } + + // Step 4: Verify state tracker has correct state + stateInfo, exists := tracker.GetState("order", "ORD-001") + if !exists { + t.Fatal("Expected state to exist in tracker") + } + if stateInfo.CurrentState != "notified" { + t.Errorf("Expected tracker state 'notified', got %q", stateInfo.CurrentState) + } + + // Step 5: Record metrics for the workflow execution + metrics.RecordWorkflowExecution("order-processing", "process", "success") + metrics.RecordWorkflowDuration("order-processing", "process", 100*time.Millisecond) + + // Verify health checker can register and run checks + health.RegisterCheck("state-engine", func(ctx context.Context) module.HealthCheckResult { + return module.HealthCheckResult{Status: "healthy", Message: "State machine engine running"} + }) + + _ = engine // engine created but pipeline exercised via direct module calls +} + +// TestOrderProcessingPipeline_ErrorPath verifies that an invalid order +// transitions to the failed state via the fail_validation transition. +func TestOrderProcessingPipeline_ErrorPath(t *testing.T) { + app := newMockApplication() + + stateMachineEngine := module.NewStateMachineEngine("order-state-engine") + tracker := module.NewStateTracker("order-state-tracker") + + if err := stateMachineEngine.Init(app); err != nil { + t.Fatalf("Failed to init state machine engine: %v", err) + } + if err := tracker.Init(app); err != nil { + t.Fatalf("Failed to init tracker: %v", err) + } + + // Register the same definition + err := stateMachineEngine.RegisterDefinition(&module.StateMachineDefinition{ + Name: "order-processing", + Description: "Order processing workflow", + InitialState: "received", + States: map[string]*module.State{ + "received": {Name: "received"}, + "validated": {Name: "validated"}, + "stored": {Name: "stored"}, + "notified": {Name: "notified", IsFinal: true}, + "failed": {Name: "failed", IsFinal: true, IsError: true}, + }, + Transitions: map[string]*module.Transition{ + "validate_order": {Name: "validate_order", FromState: "received", ToState: "validated"}, + "store_order": {Name: "store_order", FromState: "validated", ToState: "stored"}, + "send_notification": {Name: "send_notification", FromState: "stored", ToState: "notified"}, + "fail_validation": {Name: "fail_validation", FromState: "received", ToState: "failed"}, + }, + }) + if err != nil { + t.Fatalf("Failed to register definition: %v", err) + } + + ctx := context.Background() + + // Create a workflow for an invalid order + instance, err := stateMachineEngine.CreateWorkflow("order-processing", "ORD-BAD", map[string]interface{}{ + "orderId": "ORD-BAD", + "invalid": true, + }) + if err != nil { + t.Fatalf("CreateWorkflow failed: %v", err) + } + + if instance.CurrentState != "received" { + t.Fatalf("Expected initial state 'received', got %q", instance.CurrentState) + } + + // Transition: received -> failed via fail_validation + err = stateMachineEngine.TriggerTransition(ctx, "ORD-BAD", "fail_validation", map[string]interface{}{ + "reason": "Missing required fields", + }) + if err != nil { + t.Fatalf("fail_validation transition failed: %v", err) + } + + // Verify the workflow is now in the failed state + inst, err := stateMachineEngine.GetInstance("ORD-BAD") + if err != nil { + t.Fatalf("GetInstance failed: %v", err) + } + + if inst.CurrentState != "failed" { + t.Errorf("Expected state 'failed', got %q", inst.CurrentState) + } + if !inst.Completed { + t.Error("Expected workflow to be marked as completed (failed is a final state)") + } + if inst.Error == "" { + t.Error("Expected error message to be set for error state") + } + + // Verify that further transitions from 'failed' are not possible + err = stateMachineEngine.TriggerTransition(ctx, "ORD-BAD", "validate_order", nil) + if err == nil { + t.Error("Expected error when trying to transition from 'failed' state, but got nil") + } + + // Track state in state tracker and verify + tracker.SetState("order", "ORD-BAD", "failed", map[string]interface{}{ + "reason": "Missing required fields", + }) + + stateInfo, exists := tracker.GetState("order", "ORD-BAD") + if !exists { + t.Fatal("Expected state to exist in tracker") + } + if stateInfo.CurrentState != "failed" { + t.Errorf("Expected tracker state 'failed', got %q", stateInfo.CurrentState) + } +} + +// TestOrderProcessingPipeline_TransformChain tests the DataTransformer with +// map, filter, and convert operations chained together. +func TestOrderProcessingPipeline_TransformChain(t *testing.T) { + transformer := module.NewDataTransformer("order-transformer") + + ctx := context.Background() + + t.Run("map_then_filter", func(t *testing.T) { + transformer.RegisterPipeline(&module.TransformPipeline{ + Name: "map-and-filter", + Operations: []module.TransformOperation{ + { + Type: "map", + Config: map[string]interface{}{ + "mappings": map[string]interface{}{ + "cust_name": "customerName", + "cust_id": "customerId", + }, + }, + }, + { + Type: "filter", + Config: map[string]interface{}{ + "fields": []interface{}{"customerName", "customerId", "total"}, + }, + }, + }, + }) + + input := map[string]interface{}{ + "cust_name": "Bob", + "cust_id": "C-123", + "total": 49.99, + "internal": "should-be-removed", + } + + result, err := transformer.Transform(ctx, "map-and-filter", input) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + + resultMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result, got %T", result) + } + + if resultMap["customerName"] != "Bob" { + t.Errorf("Expected customerName='Bob', got %v", resultMap["customerName"]) + } + if resultMap["customerId"] != "C-123" { + t.Errorf("Expected customerId='C-123', got %v", resultMap["customerId"]) + } + if resultMap["total"] != 49.99 { + t.Errorf("Expected total=49.99, got %v", resultMap["total"]) + } + if _, exists := resultMap["internal"]; exists { + t.Error("Expected 'internal' field to be filtered out") + } + if _, exists := resultMap["cust_name"]; exists { + t.Error("Expected 'cust_name' to be removed after mapping") + } + }) + + t.Run("extract_nested", func(t *testing.T) { + transformer.RegisterPipeline(&module.TransformPipeline{ + Name: "extract-customer", + Operations: []module.TransformOperation{ + { + Type: "extract", + Config: map[string]interface{}{ + "path": "order.customer", + }, + }, + }, + }) + + input := map[string]interface{}{ + "order": map[string]interface{}{ + "customer": map[string]interface{}{ + "name": "Charlie", + "email": "charlie@example.com", + }, + "items": []interface{}{"item1", "item2"}, + }, + } + + result, err := transformer.Transform(ctx, "extract-customer", input) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + + resultMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result, got %T", result) + } + if resultMap["name"] != "Charlie" { + t.Errorf("Expected name='Charlie', got %v", resultMap["name"]) + } + }) + + t.Run("convert_json_roundtrip", func(t *testing.T) { + transformer.RegisterPipeline(&module.TransformPipeline{ + Name: "json-roundtrip", + Operations: []module.TransformOperation{ + { + Type: "convert", + Config: map[string]interface{}{"from": "json", "to": "string"}, + }, + { + Type: "convert", + Config: map[string]interface{}{"from": "string", "to": "json"}, + }, + }, + }) + + input := map[string]interface{}{ + "orderId": "ORD-100", + "amount": 42.0, + } + + result, err := transformer.Transform(ctx, "json-roundtrip", input) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + + resultMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result, got %T", result) + } + if resultMap["orderId"] != "ORD-100" { + t.Errorf("Expected orderId='ORD-100', got %v", resultMap["orderId"]) + } + if resultMap["amount"] != 42.0 { + t.Errorf("Expected amount=42.0, got %v", resultMap["amount"]) + } + }) + + t.Run("filter_only", func(t *testing.T) { + transformer.RegisterPipeline(&module.TransformPipeline{ + Name: "filter-sensitive", + Operations: []module.TransformOperation{ + { + Type: "filter", + Config: map[string]interface{}{ + "fields": []interface{}{"orderId", "status"}, + }, + }, + }, + }) + + input := map[string]interface{}{ + "orderId": "ORD-200", + "status": "received", + "creditCard": "4111-xxxx-xxxx-1111", + "ssn": "xxx-xx-xxxx", + } + + result, err := transformer.Transform(ctx, "filter-sensitive", input) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + + resultMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result, got %T", result) + } + + if len(resultMap) != 2 { + t.Errorf("Expected 2 fields after filter, got %d", len(resultMap)) + } + if _, exists := resultMap["creditCard"]; exists { + t.Error("Expected 'creditCard' to be filtered out") + } + if _, exists := resultMap["ssn"]; exists { + t.Error("Expected 'ssn' to be filtered out") + } + }) +} diff --git a/ui/e2e/connections.spec.ts b/ui/e2e/connections.spec.ts index 46e27ad8..6c6642ec 100644 --- a/ui/e2e/connections.spec.ts +++ b/ui/e2e/connections.spec.ts @@ -67,6 +67,7 @@ async function dragModuleToCanvas( 'Request ID Middleware': 'http.middleware.requestid', 'Data Transformer': 'data.transformer', 'Webhook Sender': 'webhook.sender', + 'EventBus Bridge': 'messaging.broker.eventbus', }; const modType = moduleTypeMap[label]; diff --git a/ui/e2e/exploratory.spec.ts b/ui/e2e/exploratory.spec.ts index 0d44f07b..0f59a170 100644 --- a/ui/e2e/exploratory.spec.ts +++ b/ui/e2e/exploratory.spec.ts @@ -75,6 +75,7 @@ async function dragModuleToCanvas( 'Request ID Middleware': 'http.middleware.requestid', 'Data Transformer': 'data.transformer', 'Webhook Sender': 'webhook.sender', + 'EventBus Bridge': 'messaging.broker.eventbus', }; const modType = moduleTypeMap[label]; diff --git a/ui/e2e/fixtures/phase6-all-modules.yaml b/ui/e2e/fixtures/phase6-all-modules.yaml new file mode 100644 index 00000000..324dac6d --- /dev/null +++ b/ui/e2e/fixtures/phase6-all-modules.yaml @@ -0,0 +1,63 @@ +modules: + - name: http-server + type: http.server + - name: http-router + type: http.router + - name: http-handler + type: http.handler + - name: http-proxy + type: http.proxy + - name: api-handler + type: api.handler + - name: auth-middleware + type: http.middleware.auth + - name: logging-middleware + type: http.middleware.logging + - name: rate-limiter + type: http.middleware.ratelimit + - name: cors-middleware + type: http.middleware.cors + - name: message-broker + type: messaging.broker + - name: message-handler + type: messaging.handler + - name: eventbus-bridge + type: messaging.broker.eventbus + - name: state-machine + type: statemachine.engine + - name: state-tracker + type: state.tracker + - name: state-connector + type: state.connector + - name: scheduler + type: scheduler.modular + - name: auth-service + type: auth.modular + - name: event-bus + type: eventbus.modular + - name: cache + type: cache.modular + - name: chi-mux-router + type: chimux.router + - name: event-logger + type: eventlogger.modular + - name: http-client + type: httpclient.modular + - name: database + type: database.modular + - name: json-schema-validator + type: jsonschema.modular + - name: workflow-database + type: database.workflow + - name: metrics-collector + type: metrics.collector + - name: health-checker + type: health.checker + - name: request-id-middleware + type: http.middleware.requestid + - name: data-transformer + type: data.transformer + - name: webhook-sender + type: webhook.sender +workflows: {} +triggers: {} \ No newline at end of file diff --git a/ui/e2e/fixtures/phase6-connected.yaml b/ui/e2e/fixtures/phase6-connected.yaml new file mode 100644 index 00000000..55103e1b --- /dev/null +++ b/ui/e2e/fixtures/phase6-connected.yaml @@ -0,0 +1,15 @@ +modules: + - name: web-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: + - web-server + - name: api-handler + type: http.handler + dependsOn: + - api-router +workflows: {} +triggers: {} \ No newline at end of file diff --git a/ui/e2e/fixtures/phase6-delete-connected.yaml b/ui/e2e/fixtures/phase6-delete-connected.yaml new file mode 100644 index 00000000..112d34c0 --- /dev/null +++ b/ui/e2e/fixtures/phase6-delete-connected.yaml @@ -0,0 +1,11 @@ +modules: + - name: web-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: + - web-server +workflows: {} +triggers: {} \ No newline at end of file diff --git a/ui/e2e/fixtures/phase6-roundtrip.yaml b/ui/e2e/fixtures/phase6-roundtrip.yaml new file mode 100644 index 00000000..afe7989c --- /dev/null +++ b/ui/e2e/fixtures/phase6-roundtrip.yaml @@ -0,0 +1,48 @@ +modules: + - name: order-server + type: http.server + config: + address: ':8080' + - name: order-router + type: http.router + dependsOn: + - order-server + - name: order-api + type: http.handler + config: + method: POST + path: /api/orders + contentType: application/json + dependsOn: + - order-router + - name: order-transformer + type: data.transformer + config: + description: Transforms and validates incoming order data + - name: order-state-engine + type: statemachine.engine + config: + description: Manages order lifecycle state transitions + - name: order-state-tracker + type: state.tracker + config: + description: Tracks current state for all order resources + - name: order-broker + type: messaging.broker + config: + description: In-memory message broker for order events + - name: notification-handler + type: messaging.handler + config: + topic: order.completed + description: Handles notifications when orders reach terminal states + - name: order-metrics + type: metrics.collector + config: + description: Collects Prometheus metrics for the order pipeline + - name: order-health + type: health.checker + config: + description: Provides health, readiness, and liveness endpoints +workflows: {} +triggers: {} diff --git a/ui/e2e/import-export.spec.ts b/ui/e2e/import-export.spec.ts index 9c70a7b4..30ba3a42 100644 --- a/ui/e2e/import-export.spec.ts +++ b/ui/e2e/import-export.spec.ts @@ -73,6 +73,7 @@ async function dragModuleToCanvas( 'Request ID Middleware': 'http.middleware.requestid', 'Data Transformer': 'data.transformer', 'Webhook Sender': 'webhook.sender', + 'EventBus Bridge': 'messaging.broker.eventbus', }; const modType = moduleTypeMap[label]; diff --git a/ui/e2e/node-operations.spec.ts b/ui/e2e/node-operations.spec.ts index 9a66d6a6..bc16f3f9 100644 --- a/ui/e2e/node-operations.spec.ts +++ b/ui/e2e/node-operations.spec.ts @@ -92,6 +92,7 @@ async function dragModuleToCanvas( 'Request ID Middleware': 'http.middleware.requestid', 'Data Transformer': 'data.transformer', 'Webhook Sender': 'webhook.sender', + 'EventBus Bridge': 'messaging.broker.eventbus', }; const modType = moduleTypeMap[label]; diff --git a/ui/e2e/phase6-exploratory.spec.ts b/ui/e2e/phase6-exploratory.spec.ts new file mode 100644 index 00000000..f52966c6 --- /dev/null +++ b/ui/e2e/phase6-exploratory.spec.ts @@ -0,0 +1,630 @@ +import { test, expect } from '@playwright/test'; +import { dragModuleToCanvas, connectNodes, waitForNodeCount, screenshotStep, COMPLETE_MODULE_TYPE_MAP } from './helpers'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ============================================================================ +// Section 1: Full Application Walkthrough +// ============================================================================ +test.describe('Phase 6: Application Walkthrough', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.react-flow', { timeout: 15000 }); + await page.waitForTimeout(500); + }); + + test('P6-01: Empty canvas initial state', async ({ page }) => { + await expect(page.getByText('0 modules')).toBeVisible(); + await expect(page.locator('.react-flow')).toBeVisible(); + await expect(page.getByText('Select a node to edit its properties')).toBeVisible(); + await screenshotStep(page, 'phase6-01-empty-canvas'); + }); + + test('P6-02: All 10 categories in palette', async ({ page }) => { + const categories = ['HTTP', 'Middleware', 'Messaging', 'State Machine', 'Events', 'Integration', 'Scheduling', 'Infrastructure', 'Database', 'Observability']; + for (const cat of categories) { + const label = page.locator('[style*="cursor: pointer"]').filter({ hasText: cat }).first(); + await label.scrollIntoViewIfNeeded(); + await expect(label).toBeVisible({ timeout: 5000 }); + } + await screenshotStep(page, 'phase6-02-all-categories'); + }); + + test('P6-03: Toolbar buttons all visible', async ({ page }) => { + const toolbarButtons = ['Import', 'Export YAML', 'Save', 'Validate', 'Undo', 'Redo', 'Clear']; + for (const btn of toolbarButtons) { + await expect(page.getByRole('button', { name: btn })).toBeVisible(); + } + await screenshotStep(page, 'phase6-03-toolbar-buttons'); + }); + + test('P6-04: Drag first module from each major category', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 200, 50); + await waitForNodeCount(page, 1); + + await dragModuleToCanvas(page, 'Auth Middleware', 200, 270); + await waitForNodeCount(page, 2); + + await dragModuleToCanvas(page, 'Message Broker', 450, 50); + await waitForNodeCount(page, 3); + + await dragModuleToCanvas(page, 'State Machine', 450, 270); + await waitForNodeCount(page, 4); + + await expect(page.getByText('4 modules')).toBeVisible(); + await screenshotStep(page, 'phase6-04-drag-categories'); + }); + + test('P6-05: Configure node properties with different field types', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 300, 150); + await waitForNodeCount(page, 1); + + // Click the node to show properties + await page.locator('.react-flow__node').first().click(); + await page.waitForTimeout(300); + + await expect(page.getByText('Properties', { exact: true })).toBeVisible(); + await expect(page.getByText('http.server').last()).toBeVisible(); + + // Edit the name field + const nameInput = page.locator('label').filter({ hasText: 'Name' }).locator('input'); + await nameInput.fill('my-web-server'); + await page.waitForTimeout(300); + + // Edit the address config field + const addressInput = page.locator('label').filter({ hasText: 'Address' }).locator('input'); + await addressInput.fill(':9090'); + await page.waitForTimeout(300); + + await screenshotStep(page, 'phase6-05-property-config'); + }); + + test('P6-06: Build and connect HTTP pipeline', async ({ page }) => { + // Use exact same coordinates as deep-complex-workflows which passes reliably + await dragModuleToCanvas(page, 'HTTP Server', 200, 30); + await waitForNodeCount(page, 1); + await dragModuleToCanvas(page, 'Auth Middleware', 200, 250); + await waitForNodeCount(page, 2); + + await expect(page.getByText('2 modules')).toBeVisible(); + + // Deselect before connecting + await page.locator('.react-flow__pane').click({ position: { x: 50, y: 600 } }); + await page.waitForTimeout(300); + + // Connect HTTP Server -> Auth Middleware (single connection, proven reliable) + await connectNodes(page, 0, 1); + + // Should have at least one edge + const edgeCount = await page.locator('.react-flow__edge').count(); + expect(edgeCount).toBeGreaterThanOrEqual(1); + + await screenshotStep(page, 'phase6-06-connected-pipeline'); + }); + + test('P6-07: Undo and redo operations', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 250, 100); + await waitForNodeCount(page, 1); + + await dragModuleToCanvas(page, 'HTTP Router', 250, 350); + await waitForNodeCount(page, 2); + + // Undo + await page.getByRole('button', { name: 'Undo' }).click(); + await waitForNodeCount(page, 1); + await screenshotStep(page, 'phase6-07a-after-undo'); + + // Redo + await page.getByRole('button', { name: 'Redo' }).click(); + await waitForNodeCount(page, 2); + await screenshotStep(page, 'phase6-07b-after-redo'); + }); + + test('P6-08: Clear canvas resets everything', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 200, 100); + await waitForNodeCount(page, 1); + + await dragModuleToCanvas(page, 'Scheduler', 400, 100); + await waitForNodeCount(page, 2); + + await page.getByRole('button', { name: 'Clear' }).click(); + await waitForNodeCount(page, 0); + await expect(page.getByText('0 modules')).toBeVisible(); + await screenshotStep(page, 'phase6-08-cleared-canvas'); + }); + + test('P6-09: MiniMap and canvas controls visible', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 300, 200); + await waitForNodeCount(page, 1); + + await expect(page.locator('.react-flow__minimap')).toBeVisible(); + await expect(page.locator('.react-flow__controls')).toBeVisible(); + await expect(page.locator('.react-flow__background')).toBeVisible(); + await screenshotStep(page, 'phase6-09-canvas-controls'); + }); + + test('P6-10: Module counter updates correctly', async ({ page }) => { + await expect(page.getByText('0 modules')).toBeVisible(); + + await dragModuleToCanvas(page, 'HTTP Server', 200, 100); + await waitForNodeCount(page, 1); + await expect(page.getByText('1 module')).toBeVisible(); + + await dragModuleToCanvas(page, 'HTTP Router', 400, 100); + await waitForNodeCount(page, 2); + await expect(page.getByText('2 modules')).toBeVisible(); + + await dragModuleToCanvas(page, 'Scheduler', 300, 350); + await waitForNodeCount(page, 3); + await expect(page.getByText('3 modules')).toBeVisible(); + + await screenshotStep(page, 'phase6-10-module-counter'); + }); +}); + +// ============================================================================ +// Section 2: Order Processing Workflow +// ============================================================================ +test.describe('Phase 6: Order Processing Workflow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.react-flow', { timeout: 15000 }); + await page.waitForTimeout(500); + }); + + test('P6-11: Import order-processing-pipeline.yaml', async ({ page }) => { + const yamlPath = path.resolve(__dirname, '../../example/order-processing-pipeline.yaml'); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(yamlPath); + + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 10000 }); + await expect(page.getByText('10 modules')).toBeVisible(); + await screenshotStep(page, 'phase6-11-order-pipeline-imported'); + }); + + test('P6-12: Verify node labels after import', async ({ page }) => { + const yamlPath = path.resolve(__dirname, '../../example/order-processing-pipeline.yaml'); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(yamlPath); + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 10000 }); + + const expectedNames = [ + 'order-server', 'order-router', 'order-api', 'order-transformer', + 'order-state-engine', 'order-state-tracker', 'order-broker', + 'notification-handler', 'order-metrics', 'order-health', + ]; + + for (const name of expectedNames) { + await expect(page.locator('.react-flow__node').filter({ hasText: name }).first()).toBeVisible({ timeout: 5000 }); + } + await screenshotStep(page, 'phase6-12-node-labels'); + }); + + test('P6-13: Click node and verify properties panel', async ({ page }) => { + const yamlPath = path.resolve(__dirname, '../../example/order-processing-pipeline.yaml'); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(yamlPath); + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 10000 }); + + // Click the first node (order-server) + const serverNode = page.locator('.react-flow__node').filter({ hasText: 'order-server' }).first(); + await serverNode.click(); + await page.waitForTimeout(300); + + await expect(page.getByText('Properties', { exact: true })).toBeVisible(); + await expect(page.getByText('http.server').last()).toBeVisible(); + await screenshotStep(page, 'phase6-13-order-server-props'); + }); + + test('P6-14: Export and re-import roundtrip', async ({ page }) => { + const yamlPath = path.resolve(__dirname, '../../example/order-processing-pipeline.yaml'); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(yamlPath); + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 10000 }); + + // Export + const downloadPromise = page.waitForEvent('download', { timeout: 10000 }); + await page.getByRole('button', { name: 'Export YAML' }).click(); + const download = await downloadPromise; + const filePath = await download.path(); + expect(filePath).toBeTruthy(); + const content = fs.readFileSync(filePath!, 'utf8'); + + // Verify key types are present + expect(content).toContain('http.server'); + expect(content).toContain('http.router'); + expect(content).toContain('data.transformer'); + expect(content).toContain('statemachine.engine'); + expect(content).toContain('messaging.broker'); + expect(content).toContain('metrics.collector'); + + // Clear and reimport + await page.getByRole('button', { name: 'Clear' }).click(); + await waitForNodeCount(page, 0); + + const tmpDir = path.join(__dirname, 'fixtures'); + fs.mkdirSync(tmpDir, { recursive: true }); + const tmpPath = path.join(tmpDir, 'phase6-roundtrip.yaml'); + fs.writeFileSync(tmpPath, content, 'utf8'); + + const fileChooserPromise2 = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser2 = await fileChooserPromise2; + await fileChooser2.setFiles(tmpPath); + + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 10000 }); + await expect(page.getByText('10 modules')).toBeVisible(); + await screenshotStep(page, 'phase6-14-roundtrip-complete'); + }); + + test('P6-15: Modify node property and export', async ({ page }) => { + const yamlPath = path.resolve(__dirname, '../../example/order-processing-pipeline.yaml'); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(yamlPath); + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 10000 }); + + // Click order-server node and modify address + const serverNode = page.locator('.react-flow__node').filter({ hasText: 'order-server' }).first(); + await serverNode.click(); + await page.waitForTimeout(300); + + const addressInput = page.locator('label').filter({ hasText: 'Address' }).locator('input'); + await addressInput.fill(':9999'); + await page.waitForTimeout(300); + + // Export and verify + const downloadPromise = page.waitForEvent('download', { timeout: 10000 }); + await page.getByRole('button', { name: 'Export YAML' }).click(); + const download = await downloadPromise; + const filePath = await download.path(); + const content = fs.readFileSync(filePath!, 'utf8'); + + expect(content).toContain(':9999'); + await screenshotStep(page, 'phase6-15-modified-export'); + }); + + test('P6-16: Delete node and undo restores it', async ({ page }) => { + const yamlPath = path.resolve(__dirname, '../../example/order-processing-pipeline.yaml'); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(yamlPath); + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 10000 }); + + // Select and delete a node + const metricsNode = page.locator('.react-flow__node').filter({ hasText: 'order-metrics' }).first(); + await metricsNode.click({ force: true }); + await page.waitForTimeout(300); + + await page.getByRole('button', { name: 'Delete Node' }).click(); + await page.waitForTimeout(300); + await expect(page.locator('.react-flow__node')).toHaveCount(9, { timeout: 5000 }); + + // Undo + await page.getByRole('button', { name: 'Undo' }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(10, { timeout: 5000 }); + await screenshotStep(page, 'phase6-16-delete-undo'); + }); +}); + +// ============================================================================ +// Section 3: AI Copilot Panel +// ============================================================================ +test.describe('Phase 6: AI Copilot Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.react-flow', { timeout: 15000 }); + await page.waitForTimeout(500); + }); + + test('P6-17: Open and close AI panel', async ({ page }) => { + await page.getByRole('button', { name: 'AI Copilot' }).click(); + await expect(page.getByText('Describe your workflow')).toBeVisible(); + await screenshotStep(page, 'phase6-17-ai-panel-open'); + + // Close via toolbar toggle + await page.getByRole('button', { name: 'AI Copilot' }).click(); + await expect(page.getByText('Describe your workflow')).not.toBeVisible(); + await screenshotStep(page, 'phase6-17b-ai-panel-closed'); + }); + + test('P6-18: Quick suggestion buttons visible', async ({ page }) => { + await page.getByRole('button', { name: 'AI Copilot' }).click(); + + await expect(page.getByText('Quick start')).toBeVisible(); + await expect(page.getByText('REST API with auth and rate limiting')).toBeVisible(); + await expect(page.getByText('Event-driven microservice')).toBeVisible(); + await expect(page.getByText('HTTP proxy with logging')).toBeVisible(); + await expect(page.getByText('Scheduled data pipeline')).toBeVisible(); + await expect(page.getByText('WebSocket chat backend')).toBeVisible(); + await screenshotStep(page, 'phase6-18-quick-suggestions'); + }); + + test('P6-19: Click suggestion populates textarea', async ({ page }) => { + await page.getByRole('button', { name: 'AI Copilot' }).click(); + + await page.getByText('REST API with auth and rate limiting').click(); + const textarea = page.locator('textarea[placeholder*="REST API"]'); + await expect(textarea).toHaveValue('REST API with auth and rate limiting'); + await screenshotStep(page, 'phase6-19-suggestion-populated'); + }); + + test('P6-20: Generate button disabled when empty', async ({ page }) => { + await page.getByRole('button', { name: 'AI Copilot' }).click(); + + const textarea = page.locator('textarea[placeholder*="REST API"]'); + await textarea.fill(''); + + const generateBtn = page.getByText('Generate Workflow', { exact: true }); + await expect(generateBtn).toBeDisabled(); + await screenshotStep(page, 'phase6-20-generate-disabled'); + }); + + test('P6-21: Submit form shows error without server', async ({ page }) => { + await page.getByRole('button', { name: 'AI Copilot' }).click(); + + const textarea = page.locator('textarea[placeholder*="REST API"]'); + await textarea.fill('A simple web server with authentication'); + + const generateBtn = page.getByText('Generate Workflow', { exact: true }); + await expect(generateBtn).toBeEnabled(); + await generateBtn.click(); + await page.waitForTimeout(2000); + + // Without a backend server, expect an error indication + // The page should still be functional even after error + await expect(page.locator('.react-flow')).toBeVisible(); + await screenshotStep(page, 'phase6-21-generate-error'); + }); + + test('P6-22: Explore suggestions section', async ({ page }) => { + await page.getByRole('button', { name: 'AI Copilot' }).click(); + + await expect(page.getByText('Explore suggestions')).toBeVisible(); + const useCaseInput = page.locator('input[placeholder*="Use case"]'); + await expect(useCaseInput).toBeVisible(); + + const suggestBtn = page.getByText('Suggest', { exact: true }); + await expect(suggestBtn).toBeVisible(); + await screenshotStep(page, 'phase6-22-explore-suggestions'); + }); +}); + +// ============================================================================ +// Section 4: Component Browser +// ============================================================================ +test.describe('Phase 6: Component Browser', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.react-flow', { timeout: 15000 }); + await page.waitForTimeout(500); + }); + + test('P6-23: Open and close Component Browser', async ({ page }) => { + await page.getByRole('button', { name: 'Components' }).click(); + await expect(page.getByText('Dynamic Components')).toBeVisible(); + await screenshotStep(page, 'phase6-23-components-open'); + + // Toggle off + await page.getByRole('button', { name: 'Components' }).click(); + await expect(page.getByText('Dynamic Components')).not.toBeVisible(); + await screenshotStep(page, 'phase6-23b-components-closed'); + }); + + test('P6-24: Component Browser empty or loading state', async ({ page }) => { + await page.getByRole('button', { name: 'Components' }).click(); + await page.waitForTimeout(2000); + + await expect(page.getByText('Dynamic Components')).toBeVisible(); + await screenshotStep(page, 'phase6-24-components-state'); + }); + + test('P6-25: Create Component form visibility', async ({ page }) => { + await page.getByRole('button', { name: 'Components' }).click(); + await page.getByText('+ Create Component').click(); + + await expect(page.locator('input[placeholder="my-component"]')).toBeVisible(); + await expect(page.locator('textarea[placeholder="package main..."]')).toBeVisible(); + await screenshotStep(page, 'phase6-25-create-form'); + + // Cancel hides it + await page.getByText('Cancel', { exact: true }).click(); + await expect(page.locator('input[placeholder="my-component"]')).not.toBeVisible(); + await screenshotStep(page, 'phase6-25b-form-cancelled'); + }); +}); + +// ============================================================================ +// Section 5: Edge Cases & Stress +// ============================================================================ +test.describe('Phase 6: Edge Cases', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.react-flow', { timeout: 15000 }); + await page.waitForTimeout(500); + }); + + test('P6-26: Rapid undo/redo stability', async ({ page }) => { + // Add 3 nodes + await dragModuleToCanvas(page, 'HTTP Server', 200, 100); + await waitForNodeCount(page, 1); + await dragModuleToCanvas(page, 'HTTP Router', 400, 100); + await waitForNodeCount(page, 2); + await dragModuleToCanvas(page, 'Scheduler', 300, 350); + await waitForNodeCount(page, 3); + + const undoBtn = page.getByRole('button', { name: 'Undo' }); + const redoBtn = page.getByRole('button', { name: 'Redo' }); + + // Rapid undo 3 times + await undoBtn.click(); + await undoBtn.click(); + await undoBtn.click(); + await page.waitForTimeout(500); + await waitForNodeCount(page, 0); + + // Rapid redo 3 times + await redoBtn.click(); + await redoBtn.click(); + await redoBtn.click(); + await page.waitForTimeout(500); + await waitForNodeCount(page, 3); + + await expect(page.getByText('3 modules')).toBeVisible(); + await screenshotStep(page, 'phase6-26-rapid-undo-redo'); + }); + + test('P6-27: Delete node from imported workflow with connections', async ({ page }) => { + // Import workflow with connections, then delete a node + const yamlContent = `modules: + - name: web-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: + - web-server +workflows: {} +triggers: {}`; + + const tmpDir = path.join(__dirname, 'fixtures'); + fs.mkdirSync(tmpDir, { recursive: true }); + const tmpPath = path.join(tmpDir, 'phase6-delete-connected.yaml'); + fs.writeFileSync(tmpPath, yamlContent, 'utf8'); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(tmpPath); + + await waitForNodeCount(page, 2); + await expect(page.getByText('2 modules')).toBeVisible(); + + // Delete one of the nodes + const routerNode = page.locator('.react-flow__node').filter({ hasText: 'api-router' }).first(); + await routerNode.click({ force: true }); + await page.waitForTimeout(300); + await page.getByRole('button', { name: 'Delete Node' }).click(); + await page.waitForTimeout(500); + + await waitForNodeCount(page, 1); + await expect(page.getByText('1 module')).toBeVisible(); + await screenshotStep(page, 'phase6-27-delete-connected-node'); + }); + + test('P6-28: Keyboard Delete removes selected node', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 300, 200); + await waitForNodeCount(page, 1); + + await page.locator('.react-flow__node').first().click(); + await page.waitForTimeout(300); + + await page.keyboard.press('Delete'); + await waitForNodeCount(page, 0); + await expect(page.getByText('0 modules')).toBeVisible(); + await screenshotStep(page, 'phase6-28-keyboard-delete'); + }); + + test('P6-29: Keyboard Ctrl+Z undoes last action', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 300, 200); + await waitForNodeCount(page, 1); + + // Click pane so canvas has focus + await page.locator('.react-flow__pane').click({ position: { x: 50, y: 50 } }); + await page.waitForTimeout(200); + + await page.keyboard.press('Control+z'); + await waitForNodeCount(page, 0); + await screenshotStep(page, 'phase6-29-ctrl-z-undo'); + }); + + test('P6-30: Keyboard Ctrl+Shift+Z redoes', async ({ page }) => { + await dragModuleToCanvas(page, 'HTTP Server', 300, 200); + await waitForNodeCount(page, 1); + + await page.locator('.react-flow__pane').click({ position: { x: 50, y: 50 } }); + await page.waitForTimeout(200); + + // Undo + await page.keyboard.press('Control+z'); + await waitForNodeCount(page, 0); + + // Redo + await page.keyboard.press('Control+Shift+z'); + await waitForNodeCount(page, 1); + await screenshotStep(page, 'phase6-30-ctrl-shift-z-redo'); + }); + + test('P6-31: Import all 30 module types via large workflow', async ({ page }) => { + // Build a YAML with all module types from COMPLETE_MODULE_TYPE_MAP + const entries = Object.entries(COMPLETE_MODULE_TYPE_MAP); + const modules = entries.map(([label, type], i) => { + const name = label.toLowerCase().replace(/\s+/g, '-'); + return ` - name: ${name}\n type: ${type}`; + }); + const yamlContent = `modules:\n${modules.join('\n')}\nworkflows: {}\ntriggers: {}`; + + const tmpDir = path.join(__dirname, 'fixtures'); + fs.mkdirSync(tmpDir, { recursive: true }); + const tmpPath = path.join(tmpDir, 'phase6-all-modules.yaml'); + fs.writeFileSync(tmpPath, yamlContent, 'utf8'); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(tmpPath); + + // Should have all module nodes + await expect(page.locator('.react-flow__node')).toHaveCount(entries.length, { timeout: 15000 }); + await screenshotStep(page, 'phase6-31-all-module-types'); + }); + + test('P6-32: Validate button disabled on empty canvas', async ({ page }) => { + // The Validate button should be disabled when canvas has no modules + const validateBtn = page.getByRole('button', { name: 'Validate' }); + await expect(validateBtn).toBeVisible(); + await expect(validateBtn).toBeDisabled(); + + // Add a module and verify Validate becomes enabled + await dragModuleToCanvas(page, 'HTTP Server', 300, 200); + await waitForNodeCount(page, 1); + await expect(validateBtn).toBeEnabled(); + + await screenshotStep(page, 'phase6-32-validate-button'); + }); + + test('P6-33: Category collapse and expand toggle', async ({ page }) => { + // Collapse HTTP category + const httpHeader = page.locator('[style*="cursor: pointer"]').filter({ hasText: 'HTTP' }).first(); + await httpHeader.scrollIntoViewIfNeeded(); + await httpHeader.click(); + await page.waitForTimeout(400); + + // HTTP Server should be hidden + const httpServerDraggable = page.locator('[draggable="true"]').filter({ hasText: 'HTTP Server' }); + await expect(httpServerDraggable).toHaveCount(0, { timeout: 3000 }); + await screenshotStep(page, 'phase6-33a-collapsed'); + + // Re-expand + await httpHeader.click(); + await page.waitForTimeout(400); + await expect(page.getByText('HTTP Server')).toBeVisible(); + await screenshotStep(page, 'phase6-33b-expanded'); + }); +}); diff --git a/ui/e2e/screenshots/deep-20-import-deps.png b/ui/e2e/screenshots/deep-20-import-deps.png index a689c1ff..ff9b58ce 100644 Binary files a/ui/e2e/screenshots/deep-20-import-deps.png and b/ui/e2e/screenshots/deep-20-import-deps.png differ diff --git a/ui/e2e/screenshots/deep-21-roundtrip-config.png b/ui/e2e/screenshots/deep-21-roundtrip-config.png index c9f2982c..49204a7e 100644 Binary files a/ui/e2e/screenshots/deep-21-roundtrip-config.png and b/ui/e2e/screenshots/deep-21-roundtrip-config.png differ diff --git a/ui/e2e/screenshots/deep-22-import-error.png b/ui/e2e/screenshots/deep-22-import-error.png index 8e777353..d2b949ab 100644 Binary files a/ui/e2e/screenshots/deep-22-import-error.png and b/ui/e2e/screenshots/deep-22-import-error.png differ diff --git a/ui/e2e/screenshots/deep-34-toast-error.png b/ui/e2e/screenshots/deep-34-toast-error.png index 410f95a0..8e777353 100644 Binary files a/ui/e2e/screenshots/deep-34-toast-error.png and b/ui/e2e/screenshots/deep-34-toast-error.png differ diff --git a/ui/e2e/screenshots/exp-06-yaml-roundtrip.png b/ui/e2e/screenshots/exp-06-yaml-roundtrip.png index d8691b60..e3445fce 100644 Binary files a/ui/e2e/screenshots/exp-06-yaml-roundtrip.png and b/ui/e2e/screenshots/exp-06-yaml-roundtrip.png differ diff --git a/ui/e2e/screenshots/phase6-01-empty-canvas.png b/ui/e2e/screenshots/phase6-01-empty-canvas.png new file mode 100644 index 00000000..8e777353 Binary files /dev/null and b/ui/e2e/screenshots/phase6-01-empty-canvas.png differ diff --git a/ui/e2e/screenshots/phase6-02-all-categories.png b/ui/e2e/screenshots/phase6-02-all-categories.png new file mode 100644 index 00000000..a9ea0d52 Binary files /dev/null and b/ui/e2e/screenshots/phase6-02-all-categories.png differ diff --git a/ui/e2e/screenshots/phase6-03-toolbar-buttons.png b/ui/e2e/screenshots/phase6-03-toolbar-buttons.png new file mode 100644 index 00000000..8e777353 Binary files /dev/null and b/ui/e2e/screenshots/phase6-03-toolbar-buttons.png differ diff --git a/ui/e2e/screenshots/phase6-04-drag-categories.png b/ui/e2e/screenshots/phase6-04-drag-categories.png new file mode 100644 index 00000000..743cf060 Binary files /dev/null and b/ui/e2e/screenshots/phase6-04-drag-categories.png differ diff --git a/ui/e2e/screenshots/phase6-05-property-config.png b/ui/e2e/screenshots/phase6-05-property-config.png new file mode 100644 index 00000000..b6624e29 Binary files /dev/null and b/ui/e2e/screenshots/phase6-05-property-config.png differ diff --git a/ui/e2e/screenshots/phase6-06-connected-pipeline.png b/ui/e2e/screenshots/phase6-06-connected-pipeline.png new file mode 100644 index 00000000..d289a2dc Binary files /dev/null and b/ui/e2e/screenshots/phase6-06-connected-pipeline.png differ diff --git a/ui/e2e/screenshots/phase6-07a-after-undo.png b/ui/e2e/screenshots/phase6-07a-after-undo.png new file mode 100644 index 00000000..2e02b0c7 Binary files /dev/null and b/ui/e2e/screenshots/phase6-07a-after-undo.png differ diff --git a/ui/e2e/screenshots/phase6-07b-after-redo.png b/ui/e2e/screenshots/phase6-07b-after-redo.png new file mode 100644 index 00000000..5c07b42c Binary files /dev/null and b/ui/e2e/screenshots/phase6-07b-after-redo.png differ diff --git a/ui/e2e/screenshots/phase6-08-cleared-canvas.png b/ui/e2e/screenshots/phase6-08-cleared-canvas.png new file mode 100644 index 00000000..48c17615 Binary files /dev/null and b/ui/e2e/screenshots/phase6-08-cleared-canvas.png differ diff --git a/ui/e2e/screenshots/phase6-09-canvas-controls.png b/ui/e2e/screenshots/phase6-09-canvas-controls.png new file mode 100644 index 00000000..d94b430a Binary files /dev/null and b/ui/e2e/screenshots/phase6-09-canvas-controls.png differ diff --git a/ui/e2e/screenshots/phase6-10-module-counter.png b/ui/e2e/screenshots/phase6-10-module-counter.png new file mode 100644 index 00000000..8404fb33 Binary files /dev/null and b/ui/e2e/screenshots/phase6-10-module-counter.png differ diff --git a/ui/e2e/screenshots/phase6-11-order-pipeline-imported.png b/ui/e2e/screenshots/phase6-11-order-pipeline-imported.png new file mode 100644 index 00000000..3927bb9d Binary files /dev/null and b/ui/e2e/screenshots/phase6-11-order-pipeline-imported.png differ diff --git a/ui/e2e/screenshots/phase6-12-node-labels.png b/ui/e2e/screenshots/phase6-12-node-labels.png new file mode 100644 index 00000000..a39ed0f4 Binary files /dev/null and b/ui/e2e/screenshots/phase6-12-node-labels.png differ diff --git a/ui/e2e/screenshots/phase6-13-order-server-props.png b/ui/e2e/screenshots/phase6-13-order-server-props.png new file mode 100644 index 00000000..9cb8c272 Binary files /dev/null and b/ui/e2e/screenshots/phase6-13-order-server-props.png differ diff --git a/ui/e2e/screenshots/phase6-14-roundtrip-complete.png b/ui/e2e/screenshots/phase6-14-roundtrip-complete.png new file mode 100644 index 00000000..4e948323 Binary files /dev/null and b/ui/e2e/screenshots/phase6-14-roundtrip-complete.png differ diff --git a/ui/e2e/screenshots/phase6-15-modified-export.png b/ui/e2e/screenshots/phase6-15-modified-export.png new file mode 100644 index 00000000..6f448f48 Binary files /dev/null and b/ui/e2e/screenshots/phase6-15-modified-export.png differ diff --git a/ui/e2e/screenshots/phase6-16-delete-undo.png b/ui/e2e/screenshots/phase6-16-delete-undo.png new file mode 100644 index 00000000..b8756b97 Binary files /dev/null and b/ui/e2e/screenshots/phase6-16-delete-undo.png differ diff --git a/ui/e2e/screenshots/phase6-17-ai-panel-open.png b/ui/e2e/screenshots/phase6-17-ai-panel-open.png new file mode 100644 index 00000000..38f8132f Binary files /dev/null and b/ui/e2e/screenshots/phase6-17-ai-panel-open.png differ diff --git a/ui/e2e/screenshots/phase6-17b-ai-panel-closed.png b/ui/e2e/screenshots/phase6-17b-ai-panel-closed.png new file mode 100644 index 00000000..8e777353 Binary files /dev/null and b/ui/e2e/screenshots/phase6-17b-ai-panel-closed.png differ diff --git a/ui/e2e/screenshots/phase6-18-quick-suggestions.png b/ui/e2e/screenshots/phase6-18-quick-suggestions.png new file mode 100644 index 00000000..38f8132f Binary files /dev/null and b/ui/e2e/screenshots/phase6-18-quick-suggestions.png differ diff --git a/ui/e2e/screenshots/phase6-19-suggestion-populated.png b/ui/e2e/screenshots/phase6-19-suggestion-populated.png new file mode 100644 index 00000000..e5c1d839 Binary files /dev/null and b/ui/e2e/screenshots/phase6-19-suggestion-populated.png differ diff --git a/ui/e2e/screenshots/phase6-20-generate-disabled.png b/ui/e2e/screenshots/phase6-20-generate-disabled.png new file mode 100644 index 00000000..38f8132f Binary files /dev/null and b/ui/e2e/screenshots/phase6-20-generate-disabled.png differ diff --git a/ui/e2e/screenshots/phase6-21-generate-error.png b/ui/e2e/screenshots/phase6-21-generate-error.png new file mode 100644 index 00000000..c4b55f50 Binary files /dev/null and b/ui/e2e/screenshots/phase6-21-generate-error.png differ diff --git a/ui/e2e/screenshots/phase6-22-explore-suggestions.png b/ui/e2e/screenshots/phase6-22-explore-suggestions.png new file mode 100644 index 00000000..38f8132f Binary files /dev/null and b/ui/e2e/screenshots/phase6-22-explore-suggestions.png differ diff --git a/ui/e2e/screenshots/phase6-23-components-open.png b/ui/e2e/screenshots/phase6-23-components-open.png new file mode 100644 index 00000000..566dfcdd Binary files /dev/null and b/ui/e2e/screenshots/phase6-23-components-open.png differ diff --git a/ui/e2e/screenshots/phase6-23b-components-closed.png b/ui/e2e/screenshots/phase6-23b-components-closed.png new file mode 100644 index 00000000..8e777353 Binary files /dev/null and b/ui/e2e/screenshots/phase6-23b-components-closed.png differ diff --git a/ui/e2e/screenshots/phase6-24-components-state.png b/ui/e2e/screenshots/phase6-24-components-state.png new file mode 100644 index 00000000..566dfcdd Binary files /dev/null and b/ui/e2e/screenshots/phase6-24-components-state.png differ diff --git a/ui/e2e/screenshots/phase6-25-create-form.png b/ui/e2e/screenshots/phase6-25-create-form.png new file mode 100644 index 00000000..72321112 Binary files /dev/null and b/ui/e2e/screenshots/phase6-25-create-form.png differ diff --git a/ui/e2e/screenshots/phase6-25b-form-cancelled.png b/ui/e2e/screenshots/phase6-25b-form-cancelled.png new file mode 100644 index 00000000..b6dc9f25 Binary files /dev/null and b/ui/e2e/screenshots/phase6-25b-form-cancelled.png differ diff --git a/ui/e2e/screenshots/phase6-26-rapid-undo-redo.png b/ui/e2e/screenshots/phase6-26-rapid-undo-redo.png new file mode 100644 index 00000000..eea2c4d9 Binary files /dev/null and b/ui/e2e/screenshots/phase6-26-rapid-undo-redo.png differ diff --git a/ui/e2e/screenshots/phase6-27-delete-connected-node.png b/ui/e2e/screenshots/phase6-27-delete-connected-node.png new file mode 100644 index 00000000..e14966ee Binary files /dev/null and b/ui/e2e/screenshots/phase6-27-delete-connected-node.png differ diff --git a/ui/e2e/screenshots/phase6-28-keyboard-delete.png b/ui/e2e/screenshots/phase6-28-keyboard-delete.png new file mode 100644 index 00000000..f94dd1f4 Binary files /dev/null and b/ui/e2e/screenshots/phase6-28-keyboard-delete.png differ diff --git a/ui/e2e/screenshots/phase6-29-ctrl-z-undo.png b/ui/e2e/screenshots/phase6-29-ctrl-z-undo.png new file mode 100644 index 00000000..17afe956 Binary files /dev/null and b/ui/e2e/screenshots/phase6-29-ctrl-z-undo.png differ diff --git a/ui/e2e/screenshots/phase6-30-ctrl-shift-z-redo.png b/ui/e2e/screenshots/phase6-30-ctrl-shift-z-redo.png new file mode 100644 index 00000000..11fe13cd Binary files /dev/null and b/ui/e2e/screenshots/phase6-30-ctrl-shift-z-redo.png differ diff --git a/ui/e2e/screenshots/phase6-31-all-module-types.png b/ui/e2e/screenshots/phase6-31-all-module-types.png new file mode 100644 index 00000000..e54fa7a4 Binary files /dev/null and b/ui/e2e/screenshots/phase6-31-all-module-types.png differ diff --git a/ui/e2e/screenshots/phase6-32-validate-button.png b/ui/e2e/screenshots/phase6-32-validate-button.png new file mode 100644 index 00000000..d94b430a Binary files /dev/null and b/ui/e2e/screenshots/phase6-32-validate-button.png differ diff --git a/ui/e2e/screenshots/phase6-33a-collapsed.png b/ui/e2e/screenshots/phase6-33a-collapsed.png new file mode 100644 index 00000000..d45dfb70 Binary files /dev/null and b/ui/e2e/screenshots/phase6-33a-collapsed.png differ diff --git a/ui/e2e/screenshots/phase6-33b-expanded.png b/ui/e2e/screenshots/phase6-33b-expanded.png new file mode 100644 index 00000000..8e777353 Binary files /dev/null and b/ui/e2e/screenshots/phase6-33b-expanded.png differ diff --git a/ui/e2e/toolbar.spec.ts b/ui/e2e/toolbar.spec.ts index d69b904c..ab4c1c74 100644 --- a/ui/e2e/toolbar.spec.ts +++ b/ui/e2e/toolbar.spec.ts @@ -67,6 +67,7 @@ async function dragModuleToCanvas( 'Request ID Middleware': 'http.middleware.requestid', 'Data Transformer': 'data.transformer', 'Webhook Sender': 'webhook.sender', + 'EventBus Bridge': 'messaging.broker.eventbus', }; const modType = moduleTypeMap[label];