Files
7838d00f57 feat: connector workbench (#641)
* wip: connector workbench

* wip: improve workbench

* feat(workbench): improve UI with sidebar, routing, and syntax highlighting

- Replace connector dropdown with Slack-style sidebar for connector selection
- Add URL-based routing with react-router-dom for browser navigation support
- Add JSON syntax highlighting with react-syntax-highlighter
- Rework State tab layout to list/detail view with filtering
- Update terminology from "create" to "instantiate" for connectors
- Fix duplicate selection bug in lists (ID generation collision)
- Unify data table row heights across tabs
- Increase code block font sizes for readability
- Hide navigation tabs on homepage when no connector selected
- Make CW logo clickable to return to home view

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use textarea for PEM/certificate config fields

Fields containing 'privateKey', 'certificate', or 'pem' in their name
now use a textarea instead of single-line input, preserving newlines
in PEM content that would otherwise be stripped.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review issues

- Fix data race on idCounter using atomic.Int64
- Fix path traversal vulnerability in introspect.go
- Fix pointer comparison for DefaultAsset in baseline.go

Co-authored-by: Cursor <[email protected]>

* feat(workbench): add generic connector server for integration testing

Expose the generic connector REST API from the workbench so staging
services can install a "generic connector" pointing to the local
workbench, enabling end-to-end integration testing.

- Add /generic/* routes (accounts, balances, beneficiaries, transactions)
- Return cached data from storage instead of making fresh API calls
- Add connector selection and optional API key authentication
- Add UI panel in Debug tab for configuration

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use internal registry to avoid pkg dependency

Change imports from pkg/registry to internal/connectors/plugins/registry
so the workbench can be merged before the pkg refactoring. The internal
registry will automatically delegate to pkg/registry once that refactor
lands.

Also adds light/dark mode toggle to the UI with system preference detection.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review feedback

- Add ctx.Done() checks in fetch loops to exit early on client disconnect
- Refactor generic server to get connector directly instead of via context,
  providing clearer data flow as suggested in review

Co-authored-by: Cursor <[email protected]>

* fix(workbench): include built UI files for embed directive

The //go:embed ui/dist/* directive requires files to exist at build time.
Add exception to .gitignore and commit the built UI distribution files.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address golangci-lint errors

- Remove unused globalDebugTransport variable
- Remove unused parseIntParam function
- Fix printf format for models.TaskType (use %v instead of %s)
- Add error return value handling for various function calls

Co-authored-by: Cursor <[email protected]>

* fix: remove .vite cache folder from git

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix remaining errcheck issue

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix all remaining lint issues

- Add error handling for Body.Close() calls
- Remove unnecessary fmt.Sprintf wrapper
- Simplify hex character check condition

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix errcheck in cmd/workbench.go

Co-authored-by: Cursor <[email protected]>

* chore: replace ui dist build artifacts with placeholder

The //go:embed directive needs at least one file to exist.
Instead of committing the entire built UI, commit only a
.gitkeep placeholder. The server has fallback code that
displays instructions when the full UI isn't available.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-02-12 18:29:17 +00:00

343 lines
7.7 KiB
Go

package workbench
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// Snapshot represents a captured HTTP request/response pair for testing.
type Snapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
Tags []string `json:"tags,omitempty"`
// Source info
Provider string `json:"provider"`
Operation string `json:"operation"` // e.g., "fetch_accounts", "fetch_payments"
// The captured HTTP exchange
Request SnapshotRequest `json:"request"`
Response SnapshotResponse `json:"response"`
// Original capture ID (for reference)
CaptureID string `json:"capture_id,omitempty"`
}
// SnapshotRequest represents the HTTP request part of a snapshot.
type SnapshotRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
}
// SnapshotResponse represents the HTTP response part of a snapshot.
type SnapshotResponse struct {
StatusCode int `json:"status_code"`
Status string `json:"status,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
}
// SnapshotGroup groups snapshots by operation for test generation.
type SnapshotGroup struct {
Operation string `json:"operation"`
Snapshots []Snapshot `json:"snapshots"`
}
// SnapshotManager manages test snapshots.
type SnapshotManager struct {
mu sync.RWMutex
// In-memory storage
snapshots map[string]*Snapshot
// Provider name
provider string
// Base directory for saving snapshots
baseDir string
// Debug store for accessing captured requests
debug *DebugStore
}
// NewSnapshotManager creates a new snapshot manager.
func NewSnapshotManager(provider string, debug *DebugStore) *SnapshotManager {
return &SnapshotManager{
snapshots: make(map[string]*Snapshot),
provider: provider,
debug: debug,
}
}
// SetBaseDir sets the base directory for saving snapshots.
func (m *SnapshotManager) SetBaseDir(dir string) {
m.mu.Lock()
defer m.mu.Unlock()
m.baseDir = dir
}
// SaveFromCapture creates a snapshot from a captured HTTP request.
func (m *SnapshotManager) SaveFromCapture(captureID string, name string, operation string, description string, tags []string) (*Snapshot, error) {
captured := m.debug.GetHTTPRequestByID(captureID)
if captured == nil {
return nil, fmt.Errorf("capture not found: %s", captureID)
}
snapshot := &Snapshot{
ID: uuid.New().String(),
Name: name,
Description: description,
CreatedAt: time.Now(),
Tags: tags,
Provider: m.provider,
Operation: operation,
CaptureID: captureID,
Request: SnapshotRequest{
Method: captured.Method,
URL: captured.URL,
Headers: captured.RequestHeaders,
Body: captured.RequestBody,
},
Response: SnapshotResponse{
StatusCode: captured.ResponseStatus,
Headers: captured.ResponseHeaders,
Body: captured.ResponseBody,
},
}
m.mu.Lock()
m.snapshots[snapshot.ID] = snapshot
m.mu.Unlock()
return snapshot, nil
}
// Save adds a snapshot directly.
func (m *SnapshotManager) Save(snapshot *Snapshot) error {
if snapshot.ID == "" {
snapshot.ID = uuid.New().String()
}
if snapshot.CreatedAt.IsZero() {
snapshot.CreatedAt = time.Now()
}
if snapshot.Provider == "" {
snapshot.Provider = m.provider
}
m.mu.Lock()
m.snapshots[snapshot.ID] = snapshot
m.mu.Unlock()
return nil
}
// Get returns a snapshot by ID.
func (m *SnapshotManager) Get(id string) *Snapshot {
m.mu.RLock()
defer m.mu.RUnlock()
return m.snapshots[id]
}
// List returns all snapshots, optionally filtered.
func (m *SnapshotManager) List(operation string, tags []string) []*Snapshot {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*Snapshot
for _, s := range m.snapshots {
// Filter by operation
if operation != "" && s.Operation != operation {
continue
}
// Filter by tags (all tags must match)
if len(tags) > 0 {
match := true
for _, tag := range tags {
found := false
for _, st := range s.Tags {
if st == tag {
found = true
break
}
}
if !found {
match = false
break
}
}
if !match {
continue
}
}
result = append(result, s)
}
// Sort by creation time (newest first)
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt.After(result[j].CreatedAt)
})
return result
}
// Delete removes a snapshot.
func (m *SnapshotManager) Delete(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.snapshots[id]; !exists {
return fmt.Errorf("snapshot not found: %s", id)
}
delete(m.snapshots, id)
return nil
}
// GroupByOperation groups snapshots by operation.
func (m *SnapshotManager) GroupByOperation() []SnapshotGroup {
m.mu.RLock()
defer m.mu.RUnlock()
groups := make(map[string][]Snapshot)
for _, s := range m.snapshots {
groups[s.Operation] = append(groups[s.Operation], *s)
}
var result []SnapshotGroup
for op, snapshots := range groups {
result = append(result, SnapshotGroup{
Operation: op,
Snapshots: snapshots,
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Operation < result[j].Operation
})
return result
}
// ExportToDir exports all snapshots to a directory as JSON files.
func (m *SnapshotManager) ExportToDir(dir string) error {
m.mu.RLock()
snapshots := make([]*Snapshot, 0, len(m.snapshots))
for _, s := range m.snapshots {
snapshots = append(snapshots, s)
}
m.mu.RUnlock()
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
for _, s := range snapshots {
filename := sanitizeFilename(s.Name) + ".json"
path := filepath.Join(dir, filename)
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal snapshot %s: %w", s.ID, err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("failed to write snapshot %s: %w", s.ID, err)
}
}
return nil
}
// ImportFromDir imports snapshots from a directory.
func (m *SnapshotManager) ImportFromDir(dir string) (int, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, fmt.Errorf("failed to read directory: %w", err)
}
count := 0
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
path := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
var snapshot Snapshot
if err := json.Unmarshal(data, &snapshot); err != nil {
continue
}
_ = m.Save(&snapshot)
count++
}
return count, nil
}
// Clear removes all snapshots.
func (m *SnapshotManager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.snapshots = make(map[string]*Snapshot)
}
// Stats returns snapshot statistics.
type SnapshotStats struct {
Total int `json:"total"`
ByOperation map[string]int `json:"by_operation"`
ByTag map[string]int `json:"by_tag"`
}
func (m *SnapshotManager) Stats() SnapshotStats {
m.mu.RLock()
defer m.mu.RUnlock()
stats := SnapshotStats{
Total: len(m.snapshots),
ByOperation: make(map[string]int),
ByTag: make(map[string]int),
}
for _, s := range m.snapshots {
stats.ByOperation[s.Operation]++
for _, tag := range s.Tags {
stats.ByTag[tag]++
}
}
return stats
}
func sanitizeFilename(name string) string {
// Replace unsafe characters
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
" ", "_",
)
return replacer.Replace(strings.ToLower(name))
}