Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
93a5231
feat(sandbox): add package skeleton and Options struct
yuchou87 May 8, 2026
c096e9c
style(sandbox): gofmt struct field alignment
yuchou87 May 8, 2026
dbae074
feat(sandbox): add in-memory StateStore with RWMutex
yuchou87 May 8, 2026
7072417
feat(sandbox): add ExampleStrategy, SchemaStrategy, FakerStrategy
yuchou87 May 8, 2026
579a502
feat(sandbox): add ResponseGenerator strategy chain
yuchou87 May 8, 2026
8572165
feat(sandbox): add slog multiHandler and buildLogger
yuchou87 May 8, 2026
0e9ee9b
feat(sandbox): add per-operation HTTP handler factory
yuchou87 May 8, 2026
e6026aa
fix(sandbox): fall back to path param for PUT/PATCH ID extraction
yuchou87 May 8, 2026
4f9f0c8
feat(sandbox): add SandboxServer with Start/Shutdown/Addr
yuchou87 May 8, 2026
7dd988e
test(sandbox): add httptest integration tests for CRUD flow
yuchou87 May 9, 2026
b58cb70
feat(sandbox): add caseforge sandbox cobra command
yuchou87 May 9, 2026
8d15ee3
feat(gen): add --with-sandbox flag for sandbox-integrated gen+run
yuchou87 May 9, 2026
6399a3a
fix(gen): return error on sandbox test failures; reset genWithSandbox…
yuchou87 May 9, 2026
e3c7782
test(sandbox): add acceptance tests AT-301, AT-302, AT-303
yuchou87 May 9, 2026
8632632
fix(sandbox): correct base_url variable case and strengthen AT-303
yuchou87 May 9, 2026
2101b4c
fix(run): inject base_url (hurl) vs BASE_URL (k6) per renderer conven…
yuchou87 May 9, 2026
3dd9aff
docs(sandbox): document sandbox command and --with-sandbox flag in RE…
yuchou87 May 9, 2026
48628d4
docs(sandbox): expand sandbox reference with strategy chain, CRUD det…
yuchou87 May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ caseforge lint --spec openapi.yaml

| Command | Description |
|---------|-------------|
| `sandbox` | Start a local HTTP mock server that generates realistic responses from an OpenAPI spec |
| `chain` | Generate multi-step chain cases via BFS over the dependency graph |
| `watch` | Watch a spec file and regenerate cases on change |
| `suite create` | Create a `suite.json` orchestration file |
Expand Down Expand Up @@ -157,6 +158,7 @@ caseforge lint --spec openapi.yaml
--exclude-tag string Comma-separated OpenAPI tags to exclude (e.g. 'deprecated')
--auth-bootstrap Wrap all secured-endpoint cases with an auth setup step
--with-oracles Mine response body constraints via LLM and inject as assertions (requires LLM)
--with-sandbox Start a local sandbox server, run generated cases against it, exit non-zero on failure
--force Regenerate even when spec hash matches existing output
--annotation-batch N Number of operations to annotate per LLM call (0 = one call per op; recommended: 8–20)
```
Expand Down Expand Up @@ -335,6 +337,36 @@ via `--data-pool` to seed realistic field values into generated chain probes.
--format string terminal | json (default: terminal)
```

### `caseforge sandbox`

Start a local HTTP mock server that serves realistic responses generated from an OpenAPI spec. Stop with Ctrl-C (SIGINT/SIGTERM triggers graceful shutdown).

- **Use `sandbox`** for interactive development and debugging — explore with curl or Postman, inspect logs, iterate on your spec.
- **Use `gen --with-sandbox`** for CI one-shot validation — generates cases and runs them against the sandbox automatically.

```
--spec string OpenAPI spec file (required)
--port int Listen port; 0 = random (default: 0)
--host string Listen address (default: 127.0.0.1)
--log-level string info | warn | error | silent (default: info)
--log-file string Append JSON structured logs to file (optional)
--format string Response generation strategy: auto | schema | faker (default: auto)
```

`--format auto` tries strategies in order: first uses spec examples (`x-examples` / `components/examples`), then derives zero-values from the JSON Schema, then falls back to faker-generated values.

On startup prints: `caseforge sandbox listening on http://127.0.0.1:<port>`

**Stateful CRUD:** `POST /resource` generates a response body, stores it, and returns the resource ID in both the body and the `X-Sandbox-ID` response header. A subsequent `GET /resource/{id}` returns the same stored object (200) or 404 if absent; `DELETE /resource/{id}` removes it and returns 204; `GET /resource` (no ID) returns all stored objects as a JSON array.

**CI one-shot tip:** The sandbox always returns success responses, so test cases that assert 4xx status codes will fail. Pair `--with-sandbox` with `--technique equivalence_partitioning` and a spec that defines only success responses to generate only happy-path cases:

```bash
caseforge gen --spec api.yaml --no-ai \
--technique equivalence_partitioning \
--format hurl --output ./cases --with-sandbox
```

### `caseforge watch`

```
Expand Down
2 changes: 1 addition & 1 deletion cmd/conformance.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func runConformance(cmd *cobra.Command, _ []string) error {
}

r := runner.NewHurlRunner()
result, runErr := r.Run(tmpDir, map[string]string{"BASE_URL": target})
result, runErr := r.Run(tmpDir, map[string]string{"base_url": target})
if runErr != nil {
return fmt.Errorf("running conformance tests: %w", runErr)
}
Expand Down
52 changes: 52 additions & 0 deletions cmd/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"path/filepath"
"strings"
"sync"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/fatih/color"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/testmind-hq/caseforge/internal/checkpoint"
Expand All @@ -22,6 +24,8 @@ import (
"github.com/testmind-hq/caseforge/internal/output/render"
"github.com/testmind-hq/caseforge/internal/output/schema"
"github.com/testmind-hq/caseforge/internal/output/writer"
"github.com/testmind-hq/caseforge/internal/runner"
"github.com/testmind-hq/caseforge/internal/sandbox"
"github.com/testmind-hq/caseforge/internal/webhook"
"github.com/testmind-hq/caseforge/internal/spec"
"github.com/testmind-hq/caseforge/internal/tui"
Expand Down Expand Up @@ -54,6 +58,7 @@ var (
genWithOracles bool
genForce bool
genAnnotationBatch int
genWithSandbox bool
)

// allTechniqueNames is the canonical list used for --technique completion.
Expand Down Expand Up @@ -110,6 +115,7 @@ func init() {
genCmd.Flags().BoolVar(&genWithOracles, "with-oracles", false, "Mine response body constraints via LLM and inject as assertions (requires LLM)")
genCmd.Flags().BoolVar(&genForce, "force", false, "Regenerate even when spec hash matches existing output")
genCmd.Flags().IntVar(&genAnnotationBatch, "annotation-batch", 0, "Number of operations to annotate per LLM call (0 = one call per operation, recommended: 8–20)")
genCmd.Flags().BoolVar(&genWithSandbox, "with-sandbox", false, "Start a local sandbox server and run generated cases against it")
_ = genCmd.MarkFlagRequired("spec")

// Dynamic completion: --operations reads the spec and suggests operationIds.
Expand Down Expand Up @@ -499,6 +505,13 @@ func runGen(cmd *cobra.Command, args []string) error {
_ = ckptMgr.Delete()

fmt.Fprintf(os.Stderr, "✓ Generated %d test cases → %s\n", len(cases), genOutput)

if genWithSandbox {
if err := runWithSandbox(cmd, parsedSpec, genOutput); err != nil {
return fmt.Errorf("sandbox run: %w", err)
}
}

return nil
}

Expand Down Expand Up @@ -584,6 +597,45 @@ func filterByPriority(cases []schema.TestCase, minPriority string) []schema.Test
return out
}

// runWithSandbox starts a sandbox server, runs the generated cases against it via Hurl,
// prints results, and shuts the server down. Used by gen --with-sandbox.
func runWithSandbox(cmd *cobra.Command, ps *spec.ParsedSpec, casesDir string) error {
srv := sandbox.NewSandboxServer(ps, sandbox.Options{LogLevel: "silent"})
if err := srv.Start("127.0.0.1", 0); err != nil {
return err
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}()

fmt.Fprintf(cmd.ErrOrStderr(), "sandbox ready on http://%s\n", srv.Addr())

r := runner.NewHurlRunner()
vars := map[string]string{"base_url": "http://" + srv.Addr()}
result, err := r.Run(casesDir, vars)
if err != nil {
return fmt.Errorf("running cases: %w", err)
}

out := cmd.OutOrStdout()
for _, c := range result.Cases {
if c.Passed {
color.New(color.FgGreen).Fprintf(out, " ✓ [%s] %s\n", c.ID, c.Title)
} else {
color.New(color.FgRed).Fprintf(out, " ✗ [%s] %s\n", c.ID, c.Title)
}
}
total := result.Passed + result.Failed
if result.Failed == 0 {
color.New(color.FgGreen).Fprintf(out, "✓ %d/%d sandbox tests passed\n", result.Passed, total)
return nil
}
color.New(color.FgYellow).Fprintf(out, "✗ %d/%d sandbox tests passed\n", result.Passed, total)
return fmt.Errorf("sandbox tests: %d/%d failed", result.Failed, total)
}

// splitTrimmed splits s on commas and trims whitespace from each token.
func splitTrimmed(s string) []string {
parts := strings.Split(s, ",")
Expand Down
1 change: 1 addition & 0 deletions cmd/gen_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func resetGenGlobals(t *testing.T) func() {
genResume = false
genForce = false
genAnnotationBatch = 0
genWithSandbox = false
genTupleLevel = 2
genSeed = 0
}
Expand Down
22 changes: 14 additions & 8 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ var runCmd = &cobra.Command{
Long: `Run executes generated test case files against a live API.

Supports hurl (.hurl files) and k6 (k6_tests.js) formats.
Use --target to set the API base URL (injected as BASE_URL variable).
Use --target to set the API base URL (injected as base_url for hurl, BASE_URL for k6).
Use --output to write a run-report.json with structured results.

Exit codes:
Expand All @@ -44,7 +44,7 @@ func init() {
_ = runCmd.MarkFlagRequired("cases")
runCmd.Flags().StringArray("var", nil, "Variables as key=value (repeatable)")
runCmd.Flags().StringVar(&runFormat, "format", "hurl", "Test runner format: hurl|k6")
runCmd.Flags().String("target", "", "API base URL, e.g. http://localhost:8080 (injected as BASE_URL)")
runCmd.Flags().String("target", "", "API base URL, e.g. http://localhost:8080 (injected as base_url for hurl, BASE_URL for k6)")
runCmd.Flags().String("output", "", "Directory to write run-report.json (optional)")
}

Expand All @@ -54,19 +54,25 @@ func runRun(cmd *cobra.Command, _ []string) error {
outputDir, _ := cmd.Flags().GetString("output")

vars := runner.ParseVars(varFlags)
if target != "" {
if _, alreadySet := vars["BASE_URL"]; alreadySet {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: --target overrides BASE_URL set via --var\n")
}
vars["BASE_URL"] = target
}

var r runner.Runner
switch runFormat {
case "k6":
r = runner.NewK6Runner()
if target != "" {
if _, alreadySet := vars["BASE_URL"]; alreadySet {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: --target overrides BASE_URL set via --var\n")
}
vars["BASE_URL"] = target
}
default:
r = runner.NewHurlRunner()
if target != "" {
if _, alreadySet := vars["base_url"]; alreadySet {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: --target overrides base_url set via --var\n")
}
vars["base_url"] = target
}
}

result, err := r.Run(runCases, vars)
Expand Down
80 changes: 80 additions & 0 deletions cmd/sandbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// cmd/sandbox.go
package cmd

import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"

"github.com/spf13/cobra"
"github.com/testmind-hq/caseforge/internal/sandbox"
"github.com/testmind-hq/caseforge/internal/spec"
)

var (
sandboxSpec string
sandboxPort int
sandboxHost string
sandboxLogLevel string
sandboxLogFile string
sandboxFormat string
)

var sandboxCmd = &cobra.Command{
Use: "sandbox",
Short: "Start a local mock API server from an OpenAPI spec",
Long: `Sandbox starts a local HTTP server that responds to requests described
by an OpenAPI spec. It generates realistic mock responses using a
strategy chain (example → schema → faker) and tracks stateful CRUD flows.

Prints the listening address to stdout on startup. Stop with Ctrl-C.

Examples:
caseforge sandbox --spec openapi.yaml
caseforge sandbox --spec openapi.yaml --port 8080 --log-level info --log-file sandbox.log`,
RunE: runSandbox,
}

func init() {
rootCmd.AddCommand(sandboxCmd)
sandboxCmd.Flags().StringVar(&sandboxSpec, "spec", "", "OpenAPI spec file or URL (required)")
_ = sandboxCmd.MarkFlagRequired("spec")
sandboxCmd.Flags().IntVar(&sandboxPort, "port", 0, "Listen port (0 = random)")
sandboxCmd.Flags().StringVar(&sandboxHost, "host", "127.0.0.1", "Listen address")
sandboxCmd.Flags().StringVar(&sandboxLogLevel, "log-level", "info", "Log level: info|warn|error|silent")
sandboxCmd.Flags().StringVar(&sandboxLogFile, "log-file", "", "Append JSON logs to file (optional)")
sandboxCmd.Flags().StringVar(&sandboxFormat, "format", "auto", "Response strategy: auto|schema|faker")
}

func runSandbox(cmd *cobra.Command, _ []string) error {
ps, err := spec.NewLoader().Load(sandboxSpec)
if err != nil {
return fmt.Errorf("loading spec: %w", err)
}

opts := sandbox.Options{
Host: sandboxHost,
Port: sandboxPort,
LogLevel: sandboxLogLevel,
LogFile: sandboxLogFile,
Format: sandboxFormat,
}

srv := sandbox.NewSandboxServer(ps, opts)
if err := srv.Start(opts.Host, opts.Port); err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), "caseforge sandbox listening on http://%s\n", srv.Addr())

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()

shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
101 changes: 101 additions & 0 deletions internal/sandbox/generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// internal/sandbox/generator.go
package sandbox

import (
"fmt"
"strings"

gofakeit "github.com/brianvoe/gofakeit/v7"
"github.com/testmind-hq/caseforge/internal/spec"
)

// ResponseGenerator tries strategies in order and returns the first successful result.
type ResponseGenerator struct {
strategies []ResponseStrategy
}

// newResponseGenerator builds the strategy chain for the given format option.
// "auto" = ExampleStrategy → SchemaStrategy → FakerStrategy
// "schema" = SchemaStrategy only
// "faker" = FakerStrategy only
func newResponseGenerator(format string) *ResponseGenerator {
var strategies []ResponseStrategy
switch format {
case "schema":
strategies = []ResponseStrategy{&schemaStrategy{}}
case "faker":
strategies = []ResponseStrategy{newFakerStrategy()}
default: // "auto"
strategies = []ResponseStrategy{
&exampleStrategy{},
&schemaStrategy{},
newFakerStrategy(),
}
}
return &ResponseGenerator{strategies: strategies}
}

// Generate produces a (statusCode, body) for the given operation.
// For write operations (POST/PUT/PATCH), the body is always a map[string]any
// with an "id" field guaranteed to be present.
func (g *ResponseGenerator) Generate(op *spec.Operation, pathParams map[string]string) (int, map[string]any) {
statusCode := successStatusCode(op)
var body any
for _, s := range g.strategies {
if result, ok := s.Generate(op, fmt.Sprintf("%d", statusCode)); ok {
body = result
break
}
}
// Normalize to map[string]any
m := toMap(body)
// Guarantee a non-empty id field is present for write operations so StateStore can key on it
if isWriteOp(op.Method) {
if v, ok := m["id"]; !ok || fmt.Sprintf("%v", v) == "" {
m["id"] = gofakeit.UUID()
}
}
return statusCode, m
}

func successStatusCode(op *spec.Operation) int {
for _, code := range []string{"200", "201", "202"} {
if _, ok := op.Responses[code]; ok {
n := 0
fmt.Sscanf(code, "%d", &n)
return n
}
}
return 200
}

func isWriteOp(method string) bool {
m := strings.ToUpper(method)
return m == "POST" || m == "PUT" || m == "PATCH"
}

func toMap(v any) map[string]any {
if v == nil {
return map[string]any{}
}
if m, ok := v.(map[string]any); ok {
return m
}
return map[string]any{}
}

// extractID looks for common ID field names in a response body, trying path-param names too.
func extractID(body map[string]any, opPath string) string {
candidates := []string{"id", "uuid", "ID"}
for _, seg := range strings.Split(opPath, "/") {
if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") {
candidates = append(candidates, seg[1:len(seg)-1])
}
}
for _, field := range candidates {
if val, ok := body[field]; ok {
return fmt.Sprintf("%v", val)
}
}
return ""
}
Loading
Loading