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

377 lines
9.6 KiB
Go

package workbench
import (
"bytes"
"encoding/json"
"fmt"
"go/format"
"sort"
"strings"
"text/template"
"time"
)
// TestGenerator generates Go test code from snapshots.
type TestGenerator struct {
snapshots *SnapshotManager
provider string
}
// NewTestGenerator creates a new test generator.
func NewTestGenerator(snapshots *SnapshotManager, provider string) *TestGenerator {
return &TestGenerator{
snapshots: snapshots,
provider: provider,
}
}
// GeneratedTest represents a generated test file.
type GeneratedTest struct {
Filename string `json:"filename"`
Content string `json:"content"`
Package string `json:"package"`
}
// GeneratedFixture represents a generated fixture file.
type GeneratedFixture struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
// GenerateResult contains all generated files.
type GenerateResult struct {
TestFile GeneratedTest `json:"test_file"`
Fixtures []GeneratedFixture `json:"fixtures"`
Instructions string `json:"instructions"`
}
// Generate generates test code and fixtures from all snapshots.
func (g *TestGenerator) Generate() (*GenerateResult, error) {
snapshots := g.snapshots.List("", nil)
if len(snapshots) == 0 {
return nil, fmt.Errorf("no snapshots to generate tests from")
}
// Group snapshots by operation
groups := g.snapshots.GroupByOperation()
// Generate fixture files
var fixtures []GeneratedFixture
for _, group := range groups {
for i, snap := range group.Snapshots {
fixture := g.generateFixture(&snap, i)
fixtures = append(fixtures, fixture)
}
}
// Generate test file
testFile, err := g.generateTestFile(groups)
if err != nil {
return nil, err
}
// Generate instructions
instructions := g.generateInstructions()
return &GenerateResult{
TestFile: testFile,
Fixtures: fixtures,
Instructions: instructions,
}, nil
}
func (g *TestGenerator) generateFixture(snap *Snapshot, index int) GeneratedFixture {
// Create a clean response body for the fixture
filename := fmt.Sprintf("%s_%d.json", sanitizeFilename(snap.Operation), index+1)
// Try to pretty-print JSON body
body := snap.Response.Body
if json.Valid([]byte(body)) {
var parsed interface{}
if err := json.Unmarshal([]byte(body), &parsed); err == nil {
if pretty, err := json.MarshalIndent(parsed, "", " "); err == nil {
body = string(pretty)
}
}
}
return GeneratedFixture{
Filename: filename,
Content: body,
}
}
func (g *TestGenerator) generateTestFile(groups []SnapshotGroup) (GeneratedTest, error) {
data := testTemplateData{
Package: g.provider,
Provider: g.provider,
ProviderCap: capitalizeFirst(g.provider),
GeneratedAt: time.Now().Format(time.RFC3339),
Groups: make([]testGroupData, 0, len(groups)),
}
for _, group := range groups {
groupData := testGroupData{
Operation: group.Operation,
OperationCap: toGoName(group.Operation),
Fixtures: make([]testFixtureData, 0, len(group.Snapshots)),
}
for i, snap := range group.Snapshots {
fixture := testFixtureData{
Name: snap.Name,
Filename: fmt.Sprintf("%s_%d.json", sanitizeFilename(snap.Operation), i+1),
Method: snap.Request.Method,
URLPattern: extractURLPattern(snap.Request.URL),
StatusCode: snap.Response.StatusCode,
Description: snap.Description,
}
groupData.Fixtures = append(groupData.Fixtures, fixture)
}
data.Groups = append(data.Groups, groupData)
}
// Sort groups for consistent output
sort.Slice(data.Groups, func(i, j int) bool {
return data.Groups[i].Operation < data.Groups[j].Operation
})
// Execute template
tmpl, err := template.New("test").Parse(testFileTemplate)
if err != nil {
return GeneratedTest{}, fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return GeneratedTest{}, fmt.Errorf("failed to execute template: %w", err)
}
// Format the generated code
formatted, err := format.Source(buf.Bytes())
if err != nil {
// If formatting fails, return unformatted with a note
return GeneratedTest{
Filename: "client_test.go",
Content: "// WARNING: Code formatting failed, manual fixes needed\n" + buf.String(),
Package: g.provider,
}, nil
}
return GeneratedTest{
Filename: "client_test.go",
Content: string(formatted),
Package: g.provider,
}, nil
}
func (g *TestGenerator) generateInstructions() string {
connectorPath := fmt.Sprintf("internal/connectors/plugins/public/%s", g.provider)
return fmt.Sprintf(`## Test Setup Instructions
1. Create the testdata directory:
mkdir -p %s/testdata
2. Copy the fixture files to the testdata directory
3. Copy the test file to the connector directory:
cp client_test.go %s/
4. Run the tests:
go test -v ./%s/...
## What's Generated
- **Fixture files**: JSON responses captured from the real PSP API
- **Test file**: Go tests that mock HTTP responses and call your connector
## Customizing Tests
The generated tests use a mock HTTP server. You may need to:
1. Add authentication setup if your connector requires it
2. Adjust URL patterns if they don't match exactly
3. Add assertions for specific fields you care about
## Updating Fixtures
To update fixtures when the PSP API changes:
1. Run the workbench against the real API
2. Save new snapshots for changed endpoints
3. Regenerate tests
4. Review and commit the changes
`, connectorPath, connectorPath, connectorPath)
}
type testTemplateData struct {
Package string
Provider string
ProviderCap string
GeneratedAt string
Groups []testGroupData
}
type testGroupData struct {
Operation string
OperationCap string
Fixtures []testFixtureData
}
type testFixtureData struct {
Name string
Filename string
Method string
URLPattern string
StatusCode int
Description string
}
const testFileTemplate = `// Code generated by workbench. DO NOT EDIT.
// Generated at: {{.GeneratedAt}}
package {{.Package}}
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// loadFixture loads a test fixture from the testdata directory.
func loadFixture(t *testing.T, filename string) []byte {
t.Helper()
path := filepath.Join("testdata", filename)
data, err := os.ReadFile(path)
require.NoError(t, err, "failed to load fixture: %s", filename)
return data
}
// setupMockServer creates a mock HTTP server that returns fixtures based on URL patterns.
func setupMockServer(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for pattern, handler := range handlers {
if matchURLPattern(r.URL.Path, pattern) {
handler(w, r)
return
}
}
t.Logf("No handler for: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}))
}
// matchURLPattern checks if a URL path matches a pattern (simple wildcard matching).
func matchURLPattern(path, pattern string) bool {
// Simple matching: exact match or prefix match with wildcard
if pattern == path {
return true
}
if strings.HasSuffix(pattern, "*") {
return strings.HasPrefix(path, strings.TrimSuffix(pattern, "*"))
}
return false
}
{{range .Groups}}
// Test{{$.ProviderCap}}{{.OperationCap}} tests the {{.Operation}} operation.
func Test{{$.ProviderCap}}{{.OperationCap}}(t *testing.T) {
{{range .Fixtures}}
t.Run("{{.Name}}", func(t *testing.T) {
fixture := loadFixture(t, "{{.Filename}}")
server := setupMockServer(t, map[string]http.HandlerFunc{
"{{.URLPattern}}": func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "{{.Method}}", r.Method)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader({{.StatusCode}})
w.Write(fixture)
},
})
defer server.Close()
// TODO: Initialize your connector client with server.URL
// client := NewClient(server.URL, ...)
// TODO: Call the connector method and assert results
// result, err := client.{{$.OperationCap}}(...)
// require.NoError(t, err)
// require.NotNil(t, result)
// Verify fixture is valid JSON
var parsed interface{}
err := json.Unmarshal(fixture, &parsed)
require.NoError(t, err, "fixture should be valid JSON")
})
{{end}}
}
{{end}}
`
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func toGoName(s string) string {
// Convert operation name to Go-style name
// e.g., "fetch_accounts" -> "FetchAccounts"
parts := strings.Split(s, "_")
for i, part := range parts {
parts[i] = capitalizeFirst(part)
}
return strings.Join(parts, "")
}
func extractURLPattern(url string) string {
// Extract path from URL for pattern matching
// e.g., "https://api.stripe.com/v1/accounts" -> "/v1/accounts"
// Find the path part
idx := strings.Index(url, "://")
if idx >= 0 {
url = url[idx+3:]
}
idx = strings.Index(url, "/")
if idx >= 0 {
return url[idx:]
}
return "/"
}
// GenerateFixturesOnly generates only the fixture files (for updating).
func (g *TestGenerator) GenerateFixturesOnly() ([]GeneratedFixture, error) {
snapshots := g.snapshots.List("", nil)
if len(snapshots) == 0 {
return nil, fmt.Errorf("no snapshots to generate fixtures from")
}
groups := g.snapshots.GroupByOperation()
var fixtures []GeneratedFixture
for _, group := range groups {
for i, snap := range group.Snapshots {
fixture := g.generateFixture(&snap, i)
fixtures = append(fixtures, fixture)
}
}
return fixtures, nil
}
// PreviewTest returns a preview of what would be generated.
func (g *TestGenerator) PreviewTest() (*GenerateResult, error) {
return g.Generate()
}