From 41af15f9ea86220c061a3e3705e3392565475cbb Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Mon, 21 Sep 2026 12:32:42 -0700 Subject: [PATCH] Add a local UI mock server --- .agents/skills/dex-app-builder/SKILL.md | 10 + .agents/skills/dex-app-builder/upstream | 1 + .agents/skills/dex-developer/SKILL.md | 10 - .agents/skills/dex-developer/upstream | 1 - .agents/skills/dex-sdk/SKILL.md | 10 + .gitmodules | 6 +- .superverse/template.json | 6 +- AGENTS.md | 11 +- Makefile | 7 +- README.md | 31 ++- cmd/mock-server/main.go | 56 +++++ cmd/server/main.go | 13 +- cmd/server/main_test.go | 19 ++ docs/local-mock.md | 54 +++++ internal/mockserver/server.go | 192 +++++++++++++++ internal/mockserver/server_test.go | 156 ++++++++++++ internal/mockserver/store.go | 269 +++++++++++++++++++++ internal/mockserver/store_test.go | 148 ++++++++++++ internal/templatecontract/contract_test.go | 13 +- scripts/run-mock-e2e.sh | 16 ++ scripts/with-mock.sh | 59 +++++ web/e2e/mock-basic-process.spec.ts | 39 +++ web/package.json | 2 + web/src/App.test.tsx | 73 +++++- web/src/App.tsx | 120 +++++++-- web/src/MockControls.test.tsx | 61 +++++ web/src/MockControls.tsx | 73 ++++++ web/src/main.tsx | 2 +- web/src/styles.css | 11 +- web/tsconfig.json | 2 +- web/vite.config.ts | 23 +- 31 files changed, 1427 insertions(+), 67 deletions(-) create mode 100644 .agents/skills/dex-app-builder/SKILL.md create mode 160000 .agents/skills/dex-app-builder/upstream delete mode 100644 .agents/skills/dex-developer/SKILL.md delete mode 160000 .agents/skills/dex-developer/upstream create mode 100644 .agents/skills/dex-sdk/SKILL.md create mode 100644 cmd/mock-server/main.go create mode 100644 cmd/server/main_test.go create mode 100644 docs/local-mock.md create mode 100644 internal/mockserver/server.go create mode 100644 internal/mockserver/server_test.go create mode 100644 internal/mockserver/store.go create mode 100644 internal/mockserver/store_test.go create mode 100755 scripts/run-mock-e2e.sh create mode 100755 scripts/with-mock.sh create mode 100644 web/e2e/mock-basic-process.spec.ts create mode 100644 web/src/MockControls.test.tsx create mode 100644 web/src/MockControls.tsx diff --git a/.agents/skills/dex-app-builder/SKILL.md b/.agents/skills/dex-app-builder/SKILL.md new file mode 100644 index 0000000..8f29cbb --- /dev/null +++ b/.agents/skills/dex-app-builder/SKILL.md @@ -0,0 +1,10 @@ +--- +name: dex-app-builder +description: Design and build this Dex AI Platform process product from business discovery through UI validation, Go backend implementation, and local verification. +--- + +# Dex App Builder + +Read `upstream/dex-app-builder/SKILL.md` completely and follow it as the +authoritative product-development skill. Resolve every +relative reference from that upstream skill's directory. diff --git a/.agents/skills/dex-app-builder/upstream b/.agents/skills/dex-app-builder/upstream new file mode 160000 index 0000000..af3c182 --- /dev/null +++ b/.agents/skills/dex-app-builder/upstream @@ -0,0 +1 @@ +Subproject commit af3c182de5dc4765b1e402da39eabe5fe5a13c38 diff --git a/.agents/skills/dex-developer/SKILL.md b/.agents/skills/dex-developer/SKILL.md deleted file mode 100644 index 69988b5..0000000 --- a/.agents/skills/dex-developer/SKILL.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: dex-developer -description: Develop, debug, test, and operate this Superdurable Dex application. ---- - -# Dex Developer - -Read `upstream/plugins/dex/skills/dex-developer/SKILL.md` completely and follow -it as the authoritative Dex application-development skill. Resolve every -relative reference from that upstream skill's directory. diff --git a/.agents/skills/dex-developer/upstream b/.agents/skills/dex-developer/upstream deleted file mode 160000 index 3ccc473..0000000 --- a/.agents/skills/dex-developer/upstream +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3ccc473171f6fa104af62b0add538f335d6f63fe diff --git a/.agents/skills/dex-sdk/SKILL.md b/.agents/skills/dex-sdk/SKILL.md new file mode 100644 index 0000000..6f2bf8d --- /dev/null +++ b/.agents/skills/dex-sdk/SKILL.md @@ -0,0 +1,10 @@ +--- +name: dex-sdk +description: Implement, debug, test, and operate this application through the public Superdurable Dex Go SDK. +--- + +# Dex SDK + +Read `../dex-app-builder/upstream/dex-sdk/SKILL.md` completely and follow it as +the authoritative Dex SDK skill. Resolve every relative reference from that +upstream skill's directory and load only its Core and Go guidance. diff --git a/.gitmodules b/.gitmodules index cda8b49..5816ccb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule ".agents/skills/dex-developer/upstream"] - path = .agents/skills/dex-developer/upstream - url = https://github.com/superdurable/skill-dex-developer.git +[submodule ".agents/skills/dex-app-builder/upstream"] + path = .agents/skills/dex-app-builder/upstream + url = https://github.com/superdurable/dex-skills.git diff --git a/.superverse/template.json b/.superverse/template.json index 1717e58..afcefc9 100644 --- a/.superverse/template.json +++ b/.superverse/template.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, "buildProfile": "go-react-v1", - "templateVersion": "1.1.0", + "templateVersion": "1.2.0", "minimumSandboxRuntimeContractRevision": 2, "openapiSpec": "openapi/openapi.yaml", "agentInstructions": "AGENTS.md", - "dexSkill": ".agents/skills/dex-developer/SKILL.md", + "dexSkill": ".agents/skills/dex-app-builder/SKILL.md", "commands": { "bootstrap": "make bootstrap", "generate": "make generate", @@ -14,8 +14,10 @@ "testUnit": "make test-unit", "testIntegration": "make test-integration", "testE2E": "make test-e2e", + "testMockE2E": "make test-mock-e2e", "build": "make build", "dev": "make dev", + "mock": "make mock", "check": "make check" } } diff --git a/AGENTS.md b/AGENTS.md index 26fef5d..8873770 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ This is a complete Superverse `go-react-v1` application. Read `.superverse/template.json`, `openapi/openapi.yaml`, and the local -`dex-developer` skill before changing Dex behavior. +`dex-app-builder` skill before changing product behavior. Its pinned upstream +skill loads the sibling `dex-sdk` Core and Go guidance for backend work. `openapi/openapi.yaml` is the only HTTP contract source. Never edit files below `internal/api/generated` or `web/src/api/generated` by hand. Change the spec, @@ -28,7 +29,13 @@ or delete a failing check. Stable commands are `make bootstrap`, `make generate`, `make check-generated`, `make check-fdg-v2`, `make test-unit`, `make test-integration`, -`make test-e2e`, `make build`, `make dev`, and `make check`. +`make test-e2e`, `make test-mock-e2e`, `make build`, `make dev`, `make mock`, +and `make check`. + +`make mock` is the UI approval loop. It starts the Go in-memory mock API and +Vite HMR without Dex. Keep the mock implementation behind `cmd/mock-server` +and `/__mock__`; the production server must return 404 for mock controls. +Mock verification does not replace the real Dex integration and E2E tests. When structure, commands, or required tooling changes, update this file, `.superverse/template.json`, `README.md`, and contract tests together. Do not diff --git a/Makefile b/Makefile index 6d5d750..f18933e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /usr/bin/env bash -.PHONY: bootstrap generate check-generated check-fdg-v2 test-unit test-integration test-e2e build dev check +.PHONY: bootstrap generate check-generated check-fdg-v2 test-unit test-integration test-e2e test-mock-e2e build dev mock check bootstrap: go mod download go -C tools/openapi mod download @@ -19,11 +19,15 @@ test-integration: ./scripts/with-dex.sh go test -tags=integration ./... test-e2e: ./scripts/with-dex.sh ./scripts/run-e2e.sh +test-mock-e2e: + ./scripts/run-mock-e2e.sh build: npm --prefix web run build go build -o bin/basic-process ./cmd/server dev: ./scripts/with-dex.sh bash -c 'npm --prefix web run build && go run ./cmd/server' +mock: + ./scripts/with-mock.sh check: bootstrap check-generated check-fdg-v2 @test -z "$$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*' -not -path './upstream-dex/*'))" || { gofmt -d $$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*' -not -path './upstream-dex/*')); exit 1; } go mod tidy -diff @@ -31,4 +35,5 @@ check: bootstrap check-generated check-fdg-v2 $(MAKE) test-unit $(MAKE) test-integration $(MAKE) test-e2e + $(MAKE) test-mock-e2e $(MAKE) build diff --git a/README.md b/README.md index fa80190..c88b4f1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,20 @@ native Web v2 Action. Every Step has an FDG 2.0 group and explanation. ## Start locally +For the fastest UI interaction loop, start the in-memory mock API and Vite HMR: + +```bash +make bootstrap +make mock +``` + +Open . Mock Controls can advance the process, emit a +reminder, inject the next start/refresh/approval failure, or reset all state. +The server retains state across browser refreshes and resets it on restart. +See [Local mock](docs/local-mock.md) for the complete contract. + +To run the real Dex Worker and API instead: + ```bash make bootstrap make dev @@ -55,6 +69,7 @@ Never edit them manually. make test-unit make test-integration make test-e2e +make test-mock-e2e make check ``` @@ -62,6 +77,10 @@ Integration tests start a real Dex Server with `dexcli dev`. Playwright drives the production UI and uses `dexcli flow skip-timer` to exercise the reminder branch without waiting fifteen minutes. Every poll has a deadline. +Mock E2E runs the same frontend against the Go in-memory server without Dex. +It validates loading, failure recovery, reminders, approval, refresh restore, +completion, and reset. It does not prove durable execution behavior. + `make check-fdg-v2` validates `internal/process/flow.go` with rendering schema 2.0 and requires a diagnostic-free graph with `valid: true`. The required preview `dexcli` source is pinned in `DEX_WEB_V2_BASELINE`; schema v1 is not an @@ -74,14 +93,16 @@ npm, Python 3, an FDG 2.0-capable `dexcli`, Ogen's cached module dependencies, and Chromium Headless Shell. JavaScript packages remain pinned by `web/package-lock.json`. -## Dex skill +## Dex skills -The local skill entry delegates to the pinned public -`skill-dex-developer` submodule. Initialize it with: +The local `dex-app-builder` and `dex-sdk` entries delegate to one pinned public +`dex-skills` submodule. Initialize it with: ```bash git submodule update --init --recursive ``` -Template maintainers update the pin explicitly; generated applications never -follow the skill repository's `main` branch implicitly. +`dex-app-builder` is the product workflow entrypoint and loads the sibling +`dex-sdk` Core and Go guidance for backend implementation. Template maintainers +update the pin explicitly; generated applications never follow the skill +repository's `main` branch implicitly. diff --git a/cmd/mock-server/main.go b/cmd/mock-server/main.go new file mode 100644 index 0000000..0576d26 --- /dev/null +++ b/cmd/mock-server/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/superdurable/dex-template-basic-process/internal/mockserver" +) + +func main() { + if err := run(); err != nil { + slog.Error("mock server stopped", "error", err) + os.Exit(1) + } +} + +func run() error { + handler, err := mockserver.New(mockserver.NewStore(nil)) + if err != nil { + return err + } + server := &http.Server{ + Addr: environment("MOCK_API_ADDRESS", "127.0.0.1:18081"), + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + serverResult := make(chan error, 1) + go func() { serverResult <- server.ListenAndServe() }() + slog.Info("mock API listening", "address", server.Addr) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + select { + case <-ctx.Done(): + case err := <-serverResult: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("run mock HTTP server: %w", err) + } + } + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdown) +} + +func environment(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/cmd/server/main.go b/cmd/server/main.go index d314ad1..8a35aa1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -34,10 +34,7 @@ func run() error { if err != nil { return fmt.Errorf("create OpenAPI handler: %w", err) } - mux := http.NewServeMux() - mux.Handle("/api/", apiHandler) - mux.Handle("/", staticHandler("web/dist")) - server := &http.Server{Addr: ":" + environment("PORT", "8080"), Handler: mux, ReadHeaderTimeout: 5 * time.Second} + server := &http.Server{Addr: ":" + environment("PORT", "8080"), Handler: applicationHandler(apiHandler), ReadHeaderTimeout: 5 * time.Second} workerResult := runtime.StartWorker() serverResult := make(chan error, 1) go func() { serverResult <- server.ListenAndServe() }() @@ -59,6 +56,14 @@ func run() error { return server.Shutdown(shutdown) } +func applicationHandler(apiHandler http.Handler) http.Handler { + mux := http.NewServeMux() + mux.Handle("/api/", apiHandler) + mux.Handle("/__mock__/", http.NotFoundHandler()) + mux.Handle("/", staticHandler("web/dist")) + return mux +} + func staticHandler(root string) http.Handler { files := http.FileServer(http.Dir(root)) return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..538e9a9 --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestProductionHandlerDoesNotExposeMockControls(t *testing.T) { + handler := applicationHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodGet, "/__mock__/control", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("mock control status = %d, want %d", response.Code, http.StatusNotFound) + } +} diff --git a/docs/local-mock.md b/docs/local-mock.md new file mode 100644 index 0000000..c26ce3c --- /dev/null +++ b/docs/local-mock.md @@ -0,0 +1,54 @@ +# Local mock server + +Use the mock server to validate UI interactions before connecting the frontend +to a real Dex Worker. It implements the application's current OpenAPI contract +with in-memory Go state and never starts Dex. + +## Start + +```bash +make bootstrap +make mock +``` + +Open . Vite provides hot module replacement and proxies +`/api` plus `/__mock__` to the loopback-only Go mock API. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `MOCK_WEB_HOST` | `0.0.0.0` | Vite bind host | +| `MOCK_WEB_PORT` | `8080` | Browser port | +| `MOCK_API_HOST` | `127.0.0.1` | Mock API bind host | +| `MOCK_API_PORT` | `18081` | Mock API port | + +## Lifecycle + +A new process automatically moves from `started` through `validated` to +`waiting_for_approval`. Approval moves through `approved` and `executing` to +`completed`. The short delays make loading and progress visible without +changing the real fifteen-minute Dex reminder timer. + +The server keeps state across browser refreshes. Data exists only in memory and +is discarded by Reset or server restart. + +## Mock Controls + +The controls appear only when Vite starts with `VITE_MOCK_MODE=true`: + +| Control | Behavior | +| --- | --- | +| Fail next Start | The next create request returns a one-time 503. | +| Advance | Stops automatic progression for that Flow and advances one state. | +| Emit reminder | Increments the reminder count while approval is pending. | +| Fail next Refresh | Pauses polling on a one-time 503 and exposes Retry. | +| Fail next Approval | The next approval returns a one-time 503. | +| Reset | Clears all server and browser Flow state. | + +The mock-only HTTP surface is `GET /__mock__/control?flowId=...` and +`POST /__mock__/control`. The production server returns 404 for this prefix. + +## Verification boundary + +Run `make test-mock-e2e` for the mock interaction suite. Before handoff, always +run `make check`; only the real Dex integration and E2E suites prove durable +waits, RPC behavior, Worker replacement, Timer handling, and terminal state. diff --git a/internal/mockserver/server.go b/internal/mockserver/server.go new file mode 100644 index 0000000..bb1bb7e --- /dev/null +++ b/internal/mockserver/server.go @@ -0,0 +1,192 @@ +package mockserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/superdurable/dex-template-basic-process/internal/api/generated" +) + +const operationDelay = 300 * time.Millisecond + +type waitFunc func(context.Context, time.Duration) error + +type Handler struct { + store *Store + wait waitFunc +} + +type ControlRequest struct { + Action string `json:"action"` + FlowID string `json:"flowId,omitempty"` +} + +func New(store *Store) (http.Handler, error) { + return newWithWait(store, waitForDuration) +} + +func newWithWait(store *Store, wait waitFunc) (http.Handler, error) { + if store == nil { + return nil, errors.New("mock store is required") + } + handler := &Handler{store: store, wait: wait} + apiServer, err := generated.NewServer(handler, + generated.WithErrorHandler(writeGeneratedError), + generated.WithNotFound(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusNotFound, "not_found", "unknown API route") + }), + generated.WithMethodNotAllowed(func(w http.ResponseWriter, _ *http.Request, allowed string) { + w.Header().Set("Allow", allowed) + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method is not allowed") + }), + ) + if err != nil { + return nil, fmt.Errorf("create mock OpenAPI server: %w", err) + } + mux := http.NewServeMux() + mux.Handle("/api/", apiServer) + mux.HandleFunc("/__mock__/control", handler.handleControl) + return mux, nil +} + +func (handler *Handler) GetHealth(context.Context) (*generated.HealthResponse, error) { + return &generated.HealthResponse{Status: generated.HealthResponseStatusOk}, nil +} + +func (handler *Handler) CreateFlow(ctx context.Context, request *generated.CreateFlowRequest) (generated.CreateFlowRes, error) { + if err := handler.wait(ctx, operationDelay); err != nil { + return nil, err + } + view, err := handler.store.Create(request.Title) + if errors.Is(err, ErrInjectedFailure) { + response := generated.CreateFlowServiceUnavailable(errorResponse("mock_create_failed", "mock start failure")) + return &response, nil + } + return &view, err +} + +func (handler *Handler) GetFlow(_ context.Context, params generated.GetFlowParams) (generated.GetFlowRes, error) { + view, err := handler.store.Get(params.FlowId) + switch { + case err == nil: + return &view, nil + case errors.Is(err, ErrUnknownFlow): + response := generated.GetFlowNotFound(errorResponse("unknown_flow", "flow was not found")) + return &response, nil + case errors.Is(err, ErrInjectedFailure): + response := generated.GetFlowServiceUnavailable(errorResponse("mock_get_failed", "mock refresh failure")) + return &response, nil + default: + return nil, err + } +} + +func (handler *Handler) ApproveFlow(ctx context.Context, _ *generated.ApprovalRequest, params generated.ApproveFlowParams) (generated.ApproveFlowRes, error) { + if err := handler.wait(ctx, operationDelay); err != nil { + return nil, err + } + view, err := handler.store.Approve(params.FlowId) + switch { + case err == nil: + return &view, nil + case errors.Is(err, ErrUnknownFlow): + response := generated.ApproveFlowNotFound(errorResponse("unknown_flow", "flow was not found")) + return &response, nil + case errors.Is(err, ErrDuplicateApproval): + response := generated.ApproveFlowConflict(errorResponse("duplicate_approval", "approval is not pending")) + return &response, nil + case errors.Is(err, ErrInjectedFailure): + response := generated.ApproveFlowServiceUnavailable(errorResponse("mock_approval_failed", "mock approval failure")) + return &response, nil + default: + return nil, err + } +} + +func (handler *Handler) handleControl(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if request.Method == http.MethodGet { + view, err := handler.store.Control(request.URL.Query().Get("flowId")) + writeControlResponse(w, view, err) + return + } + if request.Method != http.MethodPost { + w.Header().Set("Allow", "GET, POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method is not allowed") + return + } + defer request.Body.Close() + var control ControlRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, request.Body, 64*1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&control); err != nil { + writeError(w, http.StatusBadRequest, "invalid_control", "mock control request is invalid") + return + } + var ( + view ControlView + err error + ) + switch control.Action { + case "reset": + view = handler.store.Reset() + case "advance": + view, err = handler.store.Advance(control.FlowID) + case "emit_reminder": + view, err = handler.store.EmitReminder(control.FlowID) + case "fail_next_create": + view, err = handler.store.InjectFailure(OperationCreate, control.FlowID) + case "fail_next_get": + view, err = handler.store.InjectFailure(OperationGet, control.FlowID) + case "fail_next_approve": + view, err = handler.store.InjectFailure(OperationApprove, control.FlowID) + default: + writeError(w, http.StatusBadRequest, "unknown_control", "mock control action is not supported") + return + } + writeControlResponse(w, view, err) +} + +func writeControlResponse(w http.ResponseWriter, view ControlView, err error) { + switch { + case err == nil: + writeJSON(w, http.StatusOK, view) + case errors.Is(err, ErrUnknownFlow): + writeError(w, http.StatusNotFound, "unknown_flow", "flow was not found") + default: + writeError(w, http.StatusConflict, "invalid_control_state", err.Error()) + } +} + +func waitForDuration(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func errorResponse(code, message string) generated.ErrorResponse { + return generated.ErrorResponse{Error: code, Message: message} +} + +func writeGeneratedError(_ context.Context, w http.ResponseWriter, _ *http.Request, _ error) { + writeError(w, http.StatusBadRequest, "invalid_request", "request does not match the OpenAPI contract") +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, generated.ErrorResponse{Error: code, Message: message}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/internal/mockserver/server_test.go b/internal/mockserver/server_test.go new file mode 100644 index 0000000..bd59565 --- /dev/null +++ b/internal/mockserver/server_test.go @@ -0,0 +1,156 @@ +package mockserver + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/superdurable/dex-template-basic-process/internal/api/generated" +) + +func TestServerCoversApplicationAndControlRoutes(t *testing.T) { + handler, err := newWithWait(NewStore(nil), func(context.Context, time.Duration) error { return nil }) + if err != nil { + t.Fatalf("new server: %v", err) + } + health := request(t, handler, http.MethodGet, "/api/health", nil) + if health.StatusCode != http.StatusOK { + t.Fatalf("health status = %d", health.StatusCode) + } + health.Body.Close() + + createdResponse := request(t, handler, http.MethodPost, "/api/flows", map[string]any{"title": "Mock review"}) + if createdResponse.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d, body = %s", createdResponse.StatusCode, readBody(t, createdResponse)) + } + var created generated.FlowView + decode(t, createdResponse, &created) + + advance := request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "advance", FlowID: created.FlowId}) + if advance.StatusCode != http.StatusOK { + t.Fatalf("advance status = %d", advance.StatusCode) + } + advance.Body.Close() + advance = request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "advance", FlowID: created.FlowId}) + advance.Body.Close() + + approve := request(t, handler, http.MethodPost, "/api/flows/"+created.FlowId+"/approvals", map[string]any{"approved": true}) + if approve.StatusCode != http.StatusOK { + t.Fatalf("approve status = %d, body = %s", approve.StatusCode, readBody(t, approve)) + } + approve.Body.Close() + + reset := request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "reset"}) + if reset.StatusCode != http.StatusOK { + t.Fatalf("reset status = %d", reset.StatusCode) + } + reset.Body.Close() + + missing := request(t, handler, http.MethodGet, "/api/flows/"+created.FlowId, nil) + if missing.StatusCode != http.StatusNotFound { + t.Fatalf("missing status = %d", missing.StatusCode) + } + missing.Body.Close() +} + +func TestServerMatchesEveryApplicationResponseStatus(t *testing.T) { + handler, err := newWithWait(NewStore(nil), func(context.Context, time.Duration) error { return nil }) + if err != nil { + t.Fatalf("new server: %v", err) + } + + assertStatus(t, request(t, handler, http.MethodPost, "/api/flows", map[string]any{"title": ""}), http.StatusBadRequest) + createdResponse := request(t, handler, http.MethodPost, "/api/flows", map[string]any{"title": "Contract review"}) + if createdResponse.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d, body = %s", createdResponse.StatusCode, readBody(t, createdResponse)) + } + var created generated.FlowView + decode(t, createdResponse, &created) + + assertStatus(t, request(t, handler, http.MethodGet, "/api/flows/invalid", nil), http.StatusBadRequest) + assertStatus(t, request(t, handler, http.MethodGet, "/api/flows/"+created.FlowId, nil), http.StatusOK) + assertStatus(t, request(t, handler, http.MethodGet, "/api/flows/process-00000000-0000-4000-8000-000000000000", nil), http.StatusNotFound) + assertStatus(t, request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "fail_next_get", FlowID: created.FlowId}), http.StatusOK) + assertStatus(t, request(t, handler, http.MethodGet, "/api/flows/"+created.FlowId, nil), http.StatusServiceUnavailable) + + approvalURL := "/api/flows/" + created.FlowId + "/approvals" + assertStatus(t, request(t, handler, http.MethodPost, approvalURL, map[string]any{"approved": false}), http.StatusBadRequest) + assertStatus(t, request(t, handler, http.MethodPost, "/api/flows/process-00000000-0000-4000-8000-000000000000/approvals", map[string]any{"approved": true}), http.StatusNotFound) + assertStatus(t, request(t, handler, http.MethodPost, approvalURL, map[string]any{"approved": true}), http.StatusConflict) + assertStatus(t, request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "advance", FlowID: created.FlowId}), http.StatusOK) + assertStatus(t, request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "advance", FlowID: created.FlowId}), http.StatusOK) + assertStatus(t, request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "fail_next_approve", FlowID: created.FlowId}), http.StatusOK) + assertStatus(t, request(t, handler, http.MethodPost, approvalURL, map[string]any{"approved": true}), http.StatusServiceUnavailable) + assertStatus(t, request(t, handler, http.MethodPost, approvalURL, map[string]any{"approved": true}), http.StatusOK) + assertStatus(t, request(t, handler, http.MethodPost, approvalURL, map[string]any{"approved": true}), http.StatusConflict) +} + +func TestServerReturnsInjectedFailuresAndRejectsUnknownControls(t *testing.T) { + handler, err := newWithWait(NewStore(nil), func(context.Context, time.Duration) error { return nil }) + if err != nil { + t.Fatalf("new server: %v", err) + } + inject := request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "fail_next_create"}) + inject.Body.Close() + failed := request(t, handler, http.MethodPost, "/api/flows", map[string]any{"title": "Fails"}) + if failed.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("failed create status = %d", failed.StatusCode) + } + failed.Body.Close() + unknown := request(t, handler, http.MethodPost, "/__mock__/control", ControlRequest{Action: "unsupported"}) + if unknown.StatusCode != http.StatusBadRequest { + t.Fatalf("unknown control status = %d", unknown.StatusCode) + } + unknown.Body.Close() +} + +func request(t *testing.T, handler http.Handler, method, url string, body any) *http.Response { + t.Helper() + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + t.Fatalf("encode request: %v", err) + } + reader = bytes.NewReader(encoded) + } + req := httptest.NewRequest(method, url, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder.Result() +} + +func decode(t *testing.T, response *http.Response, target any) { + t.Helper() + defer response.Body.Close() + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + t.Fatalf("decode response: %v", err) + } +} + +func readBody(t *testing.T, response *http.Response) string { + t.Helper() + defer response.Body.Close() + contents, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read response: %v", err) + } + return string(contents) +} + +func assertStatus(t *testing.T, response *http.Response, want int) { + t.Helper() + defer response.Body.Close() + if response.StatusCode != want { + contents, _ := io.ReadAll(response.Body) + t.Fatalf("status = %d, want %d, body = %s", response.StatusCode, want, contents) + } +} diff --git a/internal/mockserver/store.go b/internal/mockserver/store.go new file mode 100644 index 0000000..9452ac2 --- /dev/null +++ b/internal/mockserver/store.go @@ -0,0 +1,269 @@ +package mockserver + +import ( + "errors" + "slices" + "sync" + "time" + + "github.com/google/uuid" + "github.com/superdurable/dex-template-basic-process/internal/api/generated" +) + +const completionMessage = "approved automation completed" + +const ( + intakeValidatedAfter = 300 * time.Millisecond + intakeWaitingAfter = 700 * time.Millisecond + executionAfter = 300 * time.Millisecond + completionAfter = 900 * time.Millisecond +) + +type Operation string + +const ( + OperationCreate Operation = "create" + OperationGet Operation = "get" + OperationApprove Operation = "approve" +) + +var ( + ErrUnknownFlow = errors.New("unknown flow") + ErrDuplicateApproval = errors.New("approval is not pending") + ErrInjectedFailure = errors.New("mock operation failure") +) + +type Clock interface { + Now() time.Time +} + +type systemClock struct{} + +func (systemClock) Now() time.Time { return time.Now() } + +type flowRecord struct { + view generated.FlowView + phaseStarted time.Time + phase string + manualControl bool +} + +type Store struct { + mu sync.Mutex + clock Clock + flows map[string]*flowRecord + pendingFailures map[Operation]bool +} + +type ControlView struct { + Mode string `json:"mode"` + Flow *generated.FlowView `json:"flow,omitempty"` + PendingFailures []Operation `json:"pendingFailures"` +} + +func NewStore(clock Clock) *Store { + if clock == nil { + clock = systemClock{} + } + return &Store{ + clock: clock, + flows: make(map[string]*flowRecord), + pendingFailures: make(map[Operation]bool), + } +} + +func (store *Store) Create(title string) (generated.FlowView, error) { + store.mu.Lock() + defer store.mu.Unlock() + if store.consumeFailure(OperationCreate) { + return generated.FlowView{}, ErrInjectedFailure + } + flowID := "process-" + uuid.NewString() + record := &flowRecord{ + view: generated.FlowView{ + FlowId: flowID, Title: title, + State: generated.ProcessStateStarted, + }, + phaseStarted: store.clock.Now(), + phase: "intake", + } + store.flows[flowID] = record + return cloneView(record.view), nil +} + +func (store *Store) Get(flowID string) (generated.FlowView, error) { + store.mu.Lock() + defer store.mu.Unlock() + if store.consumeFailure(OperationGet) { + return generated.FlowView{}, ErrInjectedFailure + } + record, ok := store.flows[flowID] + if !ok { + return generated.FlowView{}, ErrUnknownFlow + } + store.materialize(record) + return cloneView(record.view), nil +} + +func (store *Store) Approve(flowID string) (generated.FlowView, error) { + store.mu.Lock() + defer store.mu.Unlock() + if store.consumeFailure(OperationApprove) { + return generated.FlowView{}, ErrInjectedFailure + } + record, ok := store.flows[flowID] + if !ok { + return generated.FlowView{}, ErrUnknownFlow + } + store.materialize(record) + if record.view.State != generated.ProcessStateWaitingForApproval && record.view.State != generated.ProcessStateReminderEmitted { + return generated.FlowView{}, ErrDuplicateApproval + } + record.view.State = generated.ProcessStateApproved + record.phaseStarted = store.clock.Now() + record.phase = "execution" + record.manualControl = false + return cloneView(record.view), nil +} + +func (store *Store) Reset() ControlView { + store.mu.Lock() + defer store.mu.Unlock() + store.flows = make(map[string]*flowRecord) + store.pendingFailures = make(map[Operation]bool) + return store.controlView(nil) +} + +func (store *Store) InjectFailure(operation Operation, flowID string) (ControlView, error) { + store.mu.Lock() + defer store.mu.Unlock() + if !slices.Contains([]Operation{OperationCreate, OperationGet, OperationApprove}, operation) { + return ControlView{}, errors.New("unsupported failure operation") + } + store.pendingFailures[operation] = true + record := store.lookupAndMaterialize(flowID) + if record == nil { + return store.controlView(nil), nil + } + return store.controlView(&record.view), nil +} + +func (store *Store) Advance(flowID string) (ControlView, error) { + store.mu.Lock() + defer store.mu.Unlock() + record, ok := store.flows[flowID] + if !ok { + return ControlView{}, ErrUnknownFlow + } + store.materialize(record) + next := map[generated.ProcessState]generated.ProcessState{ + generated.ProcessStateStarted: generated.ProcessStateValidated, + generated.ProcessStateValidated: generated.ProcessStateWaitingForApproval, + generated.ProcessStateWaitingForApproval: generated.ProcessStateApproved, + generated.ProcessStateReminderEmitted: generated.ProcessStateApproved, + generated.ProcessStateApproved: generated.ProcessStateExecuting, + generated.ProcessStateExecuting: generated.ProcessStateCompleted, + } + if state, ok := next[record.view.State]; ok { + record.view.State = state + if state == generated.ProcessStateCompleted { + record.view.Result = generated.NewOptString(completionMessage) + } + } + record.phase = "" + record.manualControl = true + return store.controlView(&record.view), nil +} + +func (store *Store) EmitReminder(flowID string) (ControlView, error) { + store.mu.Lock() + defer store.mu.Unlock() + record, ok := store.flows[flowID] + if !ok { + return ControlView{}, ErrUnknownFlow + } + store.materialize(record) + if record.view.State != generated.ProcessStateWaitingForApproval && record.view.State != generated.ProcessStateReminderEmitted { + return ControlView{}, errors.New("flow is not waiting for approval") + } + record.view.ReminderCount++ + record.view.State = generated.ProcessStateReminderEmitted + record.phase = "" + record.manualControl = true + return store.controlView(&record.view), nil +} + +func (store *Store) Control(flowID string) (ControlView, error) { + store.mu.Lock() + defer store.mu.Unlock() + if flowID == "" { + return store.controlView(nil), nil + } + record := store.lookupAndMaterialize(flowID) + if record == nil { + return ControlView{}, ErrUnknownFlow + } + return store.controlView(&record.view), nil +} + +func (store *Store) consumeFailure(operation Operation) bool { + if !store.pendingFailures[operation] { + return false + } + delete(store.pendingFailures, operation) + return true +} + +func (store *Store) lookupAndMaterialize(flowID string) *flowRecord { + if flowID == "" { + return nil + } + record := store.flows[flowID] + if record != nil { + store.materialize(record) + } + return record +} + +func (store *Store) materialize(record *flowRecord) { + if record.manualControl { + return + } + elapsed := store.clock.Now().Sub(record.phaseStarted) + switch record.phase { + case "intake": + switch { + case elapsed >= intakeWaitingAfter: + record.view.State = generated.ProcessStateWaitingForApproval + record.phase = "" + case elapsed >= intakeValidatedAfter: + record.view.State = generated.ProcessStateValidated + } + case "execution": + switch { + case elapsed >= completionAfter: + record.view.State = generated.ProcessStateCompleted + record.view.Result = generated.NewOptString(completionMessage) + record.phase = "" + case elapsed >= executionAfter: + record.view.State = generated.ProcessStateExecuting + } + } +} + +func (store *Store) controlView(flow *generated.FlowView) ControlView { + pending := make([]Operation, 0, len(store.pendingFailures)) + for _, operation := range []Operation{OperationCreate, OperationGet, OperationApprove} { + if store.pendingFailures[operation] { + pending = append(pending, operation) + } + } + view := ControlView{Mode: "mock", PendingFailures: pending} + if flow != nil { + cloned := cloneView(*flow) + view.Flow = &cloned + } + return view +} + +func cloneView(view generated.FlowView) generated.FlowView { return view } diff --git a/internal/mockserver/store_test.go b/internal/mockserver/store_test.go new file mode 100644 index 0000000..37ff979 --- /dev/null +++ b/internal/mockserver/store_test.go @@ -0,0 +1,148 @@ +package mockserver + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/superdurable/dex-template-basic-process/internal/api/generated" +) + +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (clock *fakeClock) Now() time.Time { + clock.mu.Lock() + defer clock.mu.Unlock() + return clock.now +} + +func (clock *fakeClock) Advance(duration time.Duration) { + clock.mu.Lock() + defer clock.mu.Unlock() + clock.now = clock.now.Add(duration) +} + +func TestStoreAutomaticLifecycleAndReminder(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)} + store := NewStore(clock) + created, err := store.Create("Review the launch checklist") + if err != nil { + t.Fatalf("create: %v", err) + } + if created.State != generated.ProcessStateStarted { + t.Fatalf("created state = %s", created.State) + } + clock.Advance(intakeValidatedAfter) + validated, err := store.Get(created.FlowId) + if err != nil || validated.State != generated.ProcessStateValidated { + t.Fatalf("validated = %+v, %v", validated, err) + } + clock.Advance(intakeWaitingAfter - intakeValidatedAfter) + waiting, err := store.Get(created.FlowId) + if err != nil || waiting.State != generated.ProcessStateWaitingForApproval { + t.Fatalf("waiting = %+v, %v", waiting, err) + } + reminded, err := store.EmitReminder(created.FlowId) + if err != nil { + t.Fatalf("emit reminder: %v", err) + } + if reminded.Flow.State != generated.ProcessStateReminderEmitted || reminded.Flow.ReminderCount != 1 { + t.Fatalf("reminded = %+v", reminded.Flow) + } + approved, err := store.Approve(created.FlowId) + if err != nil || approved.State != generated.ProcessStateApproved { + t.Fatalf("approved = %+v, %v", approved, err) + } + clock.Advance(executionAfter) + executing, err := store.Get(created.FlowId) + if err != nil || executing.State != generated.ProcessStateExecuting { + t.Fatalf("executing = %+v, %v", executing, err) + } + clock.Advance(completionAfter - executionAfter) + completed, err := store.Get(created.FlowId) + if err != nil || completed.State != generated.ProcessStateCompleted || completed.Result.Or("") != completionMessage { + t.Fatalf("completed = %+v, %v", completed, err) + } + if _, err := store.Approve(created.FlowId); !errors.Is(err, ErrDuplicateApproval) { + t.Fatalf("duplicate approval error = %v", err) + } +} + +func TestStoreManualControlAndReset(t *testing.T) { + store := NewStore(&fakeClock{now: time.Now()}) + created, err := store.Create("Manual process") + if err != nil { + t.Fatalf("create: %v", err) + } + want := []generated.ProcessState{ + generated.ProcessStateValidated, + generated.ProcessStateWaitingForApproval, + generated.ProcessStateApproved, + generated.ProcessStateExecuting, + generated.ProcessStateCompleted, + } + for _, state := range want { + view, advanceErr := store.Advance(created.FlowId) + if advanceErr != nil { + t.Fatalf("advance to %s: %v", state, advanceErr) + } + if view.Flow.State != state { + t.Fatalf("advanced state = %s, want %s", view.Flow.State, state) + } + } + store.Reset() + if _, err := store.Get(created.FlowId); !errors.Is(err, ErrUnknownFlow) { + t.Fatalf("get after reset = %v", err) + } +} + +func TestStoreFailuresAreConsumedOnce(t *testing.T) { + store := NewStore(&fakeClock{now: time.Now()}) + if _, err := store.InjectFailure(OperationCreate, ""); err != nil { + t.Fatalf("inject create: %v", err) + } + if _, err := store.Create("Fails once"); !errors.Is(err, ErrInjectedFailure) { + t.Fatalf("first create error = %v", err) + } + created, err := store.Create("Succeeds next") + if err != nil { + t.Fatalf("second create: %v", err) + } + for _, operation := range []Operation{OperationGet, OperationApprove} { + if _, err := store.InjectFailure(operation, created.FlowId); err != nil { + t.Fatalf("inject %s: %v", operation, err) + } + } + if _, err := store.Get(created.FlowId); !errors.Is(err, ErrInjectedFailure) { + t.Fatalf("get error = %v", err) + } + if _, err := store.Get(created.FlowId); err != nil { + t.Fatalf("second get: %v", err) + } + if _, err := store.Approve(created.FlowId); !errors.Is(err, ErrInjectedFailure) { + t.Fatalf("approve error = %v", err) + } +} + +func TestStoreAllowsConcurrentReads(t *testing.T) { + store := NewStore(nil) + created, err := store.Create("Concurrent reads") + if err != nil { + t.Fatalf("create: %v", err) + } + var wait sync.WaitGroup + for range 32 { + wait.Add(1) + go func() { + defer wait.Done() + if _, getErr := store.Get(created.FlowId); getErr != nil { + t.Errorf("get: %v", getErr) + } + }() + } + wait.Wait() +} diff --git a/internal/templatecontract/contract_test.go b/internal/templatecontract/contract_test.go index eeb17c4..3f7b4cb 100644 --- a/internal/templatecontract/contract_test.go +++ b/internal/templatecontract/contract_test.go @@ -30,7 +30,7 @@ func TestTemplateContract(t *testing.T) { if err := json.Unmarshal(manifestBytes, &contract); err != nil { t.Fatalf("decode manifest: %v", err) } - if contract.SchemaVersion != 1 || contract.BuildProfile != "go-react-v1" || contract.TemplateVersion != "1.1.0" || contract.MinimumSandboxRuntimeContractRevision != 2 { + if contract.SchemaVersion != 1 || contract.BuildProfile != "go-react-v1" || contract.TemplateVersion != "1.2.0" || contract.MinimumSandboxRuntimeContractRevision != 2 { t.Fatalf("unexpected template identity: %+v", contract) } for _, path := range []string{contract.OpenAPISpec, contract.AgentInstructions, contract.DexSkill} { @@ -50,18 +50,21 @@ func TestTemplateContract(t *testing.T) { } } gitmodules := readFile(t, filepath.Join(root, ".gitmodules")) - if !strings.Contains(gitmodules, "path = .agents/skills/dex-developer/upstream") || - !strings.Contains(gitmodules, "url = https://github.com/superdurable/skill-dex-developer.git") { + if !strings.Contains(gitmodules, "path = .agents/skills/dex-app-builder/upstream") || + !strings.Contains(gitmodules, "url = https://github.com/superdurable/dex-skills.git") { t.Fatal("Dex skill submodule path or public HTTPS URL is not allowlisted") } - command := exec.Command("git", "ls-files", "--stage", ".agents/skills/dex-developer/upstream") + if _, err := os.Stat(filepath.Join(root, ".agents/skills/dex-sdk/SKILL.md")); err != nil { + t.Fatalf("Dex SDK wrapper: %v", err) + } + command := exec.Command("git", "ls-files", "--stage", ".agents/skills/dex-app-builder/upstream") command.Dir = root output, err := command.Output() if err != nil { t.Fatalf("read Dex skill submodule pin: %v", err) } fields := strings.Fields(string(output)) - if len(fields) < 2 || fields[0] != "160000" || len(fields[1]) != 40 { + if len(fields) < 2 || fields[0] != "160000" || fields[1] != "af3c182de5dc4765b1e402da39eabe5fe5a13c38" { t.Fatalf("Dex skill is not pinned as a gitlink: %q", output) } } diff --git a/scripts/run-mock-e2e.sh b/scripts/run-mock-e2e.sh new file mode 100755 index 0000000..0ba9236 --- /dev/null +++ b/scripts/run-mock-e2e.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +free_port() { + python3 -c 'import socket; server = socket.socket(); server.bind(("127.0.0.1", 0)); print(server.getsockname()[1]); server.close()' +} + +api_port="${MOCK_API_PORT:-$(free_port)}" +web_port="${MOCK_WEB_PORT:-$(free_port)}" +MOCK_API_PORT="${api_port}" \ +MOCK_WEB_HOST="127.0.0.1" \ +MOCK_WEB_PORT="${web_port}" \ +./scripts/with-mock.sh env \ + E2E_BASE_URL="http://127.0.0.1:${web_port}" \ + E2E_MOCK=true \ + npm --prefix web run test:e2e:mock diff --git a/scripts/with-mock.sh b/scripts/with-mock.sh new file mode 100755 index 0000000..7bd8ab1 --- /dev/null +++ b/scripts/with-mock.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +mock_directory="$(mktemp -d "${TMPDIR:-/tmp}/dex-basic-process-mock.XXXXXX")" +api_host="${MOCK_API_HOST:-127.0.0.1}" +api_port="${MOCK_API_PORT:-18081}" +web_host="${MOCK_WEB_HOST:-0.0.0.0}" +web_port="${MOCK_WEB_PORT:-8080}" +api_log="${mock_directory}/api.log" +web_log="${mock_directory}/web.log" + +cleanup() { + exit_code=$? + trap - EXIT INT TERM + if [[ -n "${web_pid:-}" ]]; then kill "${web_pid}" 2>/dev/null || true; wait "${web_pid}" 2>/dev/null || true; fi + if [[ -n "${api_pid:-}" ]]; then kill "${api_pid}" 2>/dev/null || true; wait "${api_pid}" 2>/dev/null || true; fi + if [[ "${exit_code}" -eq 0 || "${exit_code}" -eq 130 || "${exit_code}" -eq 143 ]]; then + rm -rf -- "${mock_directory}" + else + cat "${api_log}" >&2 || true + cat "${web_log}" >&2 || true + echo "Mock artifacts: ${mock_directory}" >&2 + fi + exit "${exit_code}" +} +trap cleanup EXIT INT TERM + +MOCK_API_ADDRESS="${api_host}:${api_port}" go run ./cmd/mock-server >"${api_log}" 2>&1 & +api_pid=$! + +deadline=$((SECONDS + 45)) +until curl --fail --silent "http://${api_host}:${api_port}/api/health" >/dev/null; do + if ! kill -0 "${api_pid}" 2>/dev/null || (( SECONDS >= deadline )); then + cat "${api_log}" >&2 + exit 1 + fi + sleep 0.1 +done + +VITE_MOCK_MODE=true \ +VITE_MOCK_API_TARGET="http://${api_host}:${api_port}" \ +npm --prefix web run mock -- --host "${web_host}" --port "${web_port}" --strictPort >"${web_log}" 2>&1 & +web_pid=$! + +deadline=$((SECONDS + 45)) +until curl --fail --silent "http://127.0.0.1:${web_port}" >/dev/null; do + if ! kill -0 "${web_pid}" 2>/dev/null || (( SECONDS >= deadline )); then + cat "${web_log}" >&2 + exit 1 + fi + sleep 0.1 +done + +echo "Mock UI: http://127.0.0.1:${web_port}" +if (( $# > 0 )); then + "$@" +else + wait "${web_pid}" +fi diff --git a/web/e2e/mock-basic-process.spec.ts b/web/e2e/mock-basic-process.spec.ts new file mode 100644 index 0000000..fe86e34 --- /dev/null +++ b/web/e2e/mock-basic-process.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test'; + +test.skip(process.env.E2E_MOCK !== 'true', 'requires the local mock server'); + +test('exercises the complete mock UI lifecycle and failures', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Mock controls')).toBeVisible(); + + await page.getByRole('button', { name: 'Fail next Start' }).click(); + await page.getByRole('button', { name: 'Start process' }).click(); + await expect(page.getByRole('alert')).toContainText('mock start failure'); + + await page.getByRole('button', { name: 'Start process' }).click(); + const processPanel = page.locator('[data-flow-id]'); + await expect(processPanel).toBeVisible(); + await expect(processPanel.locator('.status')).toHaveText('waiting for approval'); + + await page.reload(); + await expect(processPanel).toBeVisible(); + await expect(processPanel.locator('.status')).toHaveText('waiting for approval'); + + await page.getByRole('button', { name: 'Fail next Refresh' }).click(); + await expect(page.getByRole('alert')).toContainText('mock refresh failure'); + await page.getByRole('button', { name: 'Retry' }).click(); + await expect(page.getByText('mock refresh failure')).not.toBeVisible(); + + await page.getByRole('button', { name: 'Emit reminder' }).click(); + await expect(page.getByTestId('reminder-count')).toHaveText('1'); + + await page.getByRole('button', { name: 'Fail next Approval' }).click(); + await page.getByRole('button', { name: 'Approve' }).click(); + await expect(page.getByRole('alert')).toContainText('mock approval failure'); + await page.getByRole('button', { name: 'Approve' }).click(); + await expect(page.getByTestId('result')).toHaveText('approved automation completed'); + + await page.getByRole('button', { name: 'Reset' }).click(); + await expect(processPanel).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Fail next Start' })).toBeVisible(); +}); diff --git a/web/package.json b/web/package.json index 5c5e32d..180f6d1 100644 --- a/web/package.json +++ b/web/package.json @@ -5,9 +5,11 @@ "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", + "mock": "vite", "generate": "openapi-ts --file openapi-ts.config.ts", "test": "vitest run", "test:e2e": "playwright test", + "test:e2e:mock": "playwright test e2e/mock-basic-process.spec.ts", "typecheck": "tsc --noEmit", "build": "tsc --noEmit && vite build" }, diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 31e6bdb..9c931d6 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,11 +1,78 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; +const api = vi.hoisted(() => ({ + approveFlow: vi.fn(), + createFlow: vi.fn(), + getFlow: vi.fn(), +})); + +vi.mock('./api/generated/sdk.gen', () => api); + describe('App', () => { - it('introduces the runnable approval automation', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(cleanup); + + it('introduces the runnable approval automation without production mock controls', () => { render(); expect(screen.getByRole('heading', { name: /approval automation/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /start process/i })).toBeEnabled(); + expect(screen.queryByText(/mock controls/i)).not.toBeInTheDocument(); + }); + + it('shows the development controls only in mock mode', () => { + render(); + expect(screen.getByText(/mock controls/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /fail next start/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /reset/i })).toBeEnabled(); + }); + + it('shows a visible loading state while a process starts', () => { + api.createFlow.mockReturnValue(new Promise(() => {})); + render(); + fireEvent.click(screen.getByRole('button', { name: /start process/i })); + expect(screen.getByRole('button', { name: /starting/i })).toBeDisabled(); + }); + + it('shows approval loading after a restored waiting Flow', async () => { + window.localStorage.setItem('dex-basic-process-flow-id', 'process-00000000-0000-4000-8000-000000000000'); + api.getFlow.mockResolvedValue({ data: waitingFlow }); + api.approveFlow.mockReturnValue(new Promise(() => {})); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Approve' })); + expect(screen.getByRole('button', { name: /approving/i })).toBeDisabled(); + }); + + it('pauses a failed refresh and resumes it through Retry', async () => { + window.localStorage.setItem('dex-basic-process-flow-id', 'process-00000000-0000-4000-8000-000000000000'); + api.getFlow + .mockResolvedValueOnce({ data: waitingFlow }) + .mockResolvedValueOnce({ error: { error: 'mock_get_failed', message: 'mock refresh failure' } }) + .mockResolvedValueOnce({ data: waitingFlow }); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent('mock refresh failure'); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Approve' })).toBeEnabled(); + }); + + it('clears a restored Flow when the restarted server no longer has it', async () => { + window.localStorage.setItem('dex-basic-process-flow-id', 'process-00000000-0000-4000-8000-000000000000'); + api.getFlow.mockResolvedValue({ error: { error: 'unknown_flow', message: 'flow was not found' } }); + render(); + await waitFor(() => expect(window.localStorage.getItem('dex-basic-process-flow-id')).toBeNull()); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); }); + +const waitingFlow = { + flowId: 'process-00000000-0000-4000-8000-000000000000', + title: 'Review the launch checklist', + state: 'waiting_for_approval' as const, + reminderCount: 0, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx index e2fa2b8..43e1dbd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,9 @@ -import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { approveFlow, createFlow, getFlow } from './api/generated/sdk.gen'; import type { FlowView, ProcessState } from './api/generated/types.gen'; +import { MockControls } from './MockControls'; + +const storedFlowID = 'dex-basic-process-flow-id'; const steps: Array<{ state: ProcessState; label: string }> = [ { state: 'started', label: 'Start process' }, @@ -23,45 +26,114 @@ const stateRank: Record = { const approvableStates = new Set(['waiting_for_approval', 'reminder_emitted']); -export function App() { +type AppProps = { mockMode?: boolean }; + +function responseMessage(error: unknown, fallback: string) { + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') { + return error.message; + } + return fallback; +} + +function responseCode(error: unknown) { + return error && typeof error === 'object' && 'error' in error && typeof error.error === 'string' + ? error.error + : ''; +} + +export function App({ mockMode = false }: AppProps) { const [title, setTitle] = useState('Review the launch checklist'); const [flow, setFlow] = useState(); - const [error, setError] = useState(''); - const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(''); + const [refreshError, setRefreshError] = useState(''); + const [refreshPaused, setRefreshPaused] = useState(false); + const [busy, setBusy] = useState<'start' | 'approve' | ''>(''); + const restored = useRef(false); + + const refreshFlow = useCallback(async (flowId: string, resume = false) => { + if (resume) setRefreshPaused(false); + const response = await getFlow({ path: { flowId } }); + if (response.data) { + setFlow(response.data); + setRefreshError(''); + setRefreshPaused(false); + return; + } + if (responseCode(response.error) === 'unknown_flow') { + window.localStorage.removeItem(storedFlowID); + setFlow(undefined); + setRefreshError(''); + setRefreshPaused(false); + return; + } + setRefreshError(responseMessage(response.error, 'Unable to refresh the automation.')); + setRefreshPaused(true); + }, []); useEffect(() => { - if (!flow || flow.state === 'completed') return; - const timer = window.setInterval(async () => { - const response = await getFlow({ path: { flowId: flow.flowId } }); - if (response.data) setFlow(response.data); - }, 750); - return () => window.clearInterval(timer); + if (restored.current) return; + restored.current = true; + const flowId = window.localStorage.getItem(storedFlowID); + if (flowId) void refreshFlow(flowId); + }, [refreshFlow]); + + useEffect(() => { + if (flow) window.localStorage.setItem(storedFlowID, flow.flowId); }, [flow]); + useEffect(() => { + if (!flow || flow.state === 'completed' || refreshPaused) return; + const timer = window.setInterval( + () => void refreshFlow(flow.flowId), + mockMode ? 250 : 750, + ); + return () => window.clearInterval(timer); + }, [flow, mockMode, refreshFlow, refreshPaused]); + const activeRank = useMemo(() => (flow ? stateRank[flow.state] : -1), [flow]); async function start(event: FormEvent) { event.preventDefault(); - setBusy(true); - setError(''); + setBusy('start'); + setActionError(''); + setRefreshError(''); + setRefreshPaused(false); const response = await createFlow({ body: { title } }); if (response.data) setFlow(response.data); - else setError(response.error?.message ?? 'Unable to start the automation.'); - setBusy(false); + else setActionError(responseMessage(response.error, 'Unable to start the automation.')); + setBusy(''); } async function approve() { if (!flow) return; - setBusy(true); - setError(''); + setBusy('approve'); + setActionError(''); const response = await approveFlow({ path: { flowId: flow.flowId }, body: { approved: true } }); if (response.data) setFlow(response.data); - else setError(response.error?.message ?? 'Unable to approve the automation.'); - setBusy(false); + else setActionError(responseMessage(response.error, 'Unable to approve the automation.')); + setBusy(''); + } + + function resetUI() { + window.localStorage.removeItem(storedFlowID); + setFlow(undefined); + setActionError(''); + setRefreshError(''); + setRefreshPaused(false); + setBusy(''); } return (
+ {mockMode && ( + setRefreshPaused(true)} + onRefresh={() => flow ? refreshFlow(flow.flowId) : Promise.resolve()} + onReset={resetUI} + /> + )}

SUPERDURABLE DEX

Approval automation that survives everything.

@@ -73,10 +145,16 @@ export function App() {
setTitle(event.target.value)} /> - +
- {error &&

{error}

} + {actionError &&

{actionError}

} + {refreshError && ( +
+ {refreshError} + +
+ )} {flow && ( @@ -94,7 +172,7 @@ export function App() {

Reminders emitted: {flow.reminderCount}

- {approvableStates.has(flow.state) && } + {approvableStates.has(flow.state) && }
{flow.result &&

{flow.result}

} diff --git a/web/src/MockControls.test.tsx b/web/src/MockControls.test.tsx new file mode 100644 index 0000000..ca5ea65 --- /dev/null +++ b/web/src/MockControls.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MockControls } from './MockControls'; + +const callbacks = { + onFlowChange: vi.fn(), + onPauseRefresh: vi.fn(), + onRefresh: vi.fn().mockResolvedValue(undefined), + onReset: vi.fn(), +}; + +describe('MockControls', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ mode: 'mock', pendingFailures: [] }), + })); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('offers only start failure and reset before a Flow exists', () => { + render(); + expect(screen.getByRole('button', { name: 'Fail next Start' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Advance' })).not.toBeInTheDocument(); + }); + + it('enables controls according to the current Flow state', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'Advance' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Emit reminder' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Fail next Approval' })).toBeEnabled(); + + rerender(); + expect(screen.getByRole('button', { name: 'Advance' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Emit reminder' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Fail next Approval' })).toBeDisabled(); + }); + + it('disables controls while applying Reset and clears the UI', async () => { + let resolveFetch: (value: Response) => void = () => {}; + vi.mocked(fetch).mockReturnValue(new Promise((resolve) => { resolveFetch = resolve; }) as Promise); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + resolveFetch({ ok: true, json: () => Promise.resolve({ mode: 'mock', pendingFailures: [] }) } as Response); + await waitFor(() => expect(callbacks.onReset).toHaveBeenCalledOnce()); + }); +}); + +const waitingFlow = { + flowId: 'process-00000000-0000-4000-8000-000000000000', + title: 'Review the launch checklist', + state: 'waiting_for_approval' as const, + reminderCount: 0, +}; diff --git a/web/src/MockControls.tsx b/web/src/MockControls.tsx new file mode 100644 index 0000000..369c89f --- /dev/null +++ b/web/src/MockControls.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import type { FlowView, ProcessState } from './api/generated/types.gen'; + +type MockAction = + | 'reset' + | 'advance' + | 'emit_reminder' + | 'fail_next_create' + | 'fail_next_get' + | 'fail_next_approve'; + +type MockControlView = { + mode: 'mock'; + flow?: FlowView; + pendingFailures: Array<'create' | 'get' | 'approve'>; +}; + +type MockControlsProps = { + flow?: FlowView; + onFlowChange: (flow: FlowView) => void; + onPauseRefresh: () => void; + onRefresh: () => Promise; + onReset: () => void; +}; + +const reminderStates = new Set(['waiting_for_approval', 'reminder_emitted']); + +export function MockControls({ flow, onFlowChange, onPauseRefresh, onRefresh, onReset }: MockControlsProps) { + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState('In-memory data survives browser refreshes.'); + + async function runControl(action: MockAction) { + setBusy(true); + try { + if (action === 'fail_next_get') onPauseRefresh(); + const response = await fetch('/__mock__/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, flowId: flow?.flowId }), + }); + const result = await response.json() as MockControlView | { message?: string }; + if (!response.ok) throw new Error('message' in result ? result.message : 'Mock control failed.'); + const control = result as MockControlView; + if (action === 'reset') onReset(); + else if (control.flow) onFlowChange(control.flow); + if (action === 'fail_next_get') await onRefresh(); + const pending = control.pendingFailures.length > 0 + ? ` Pending failure: ${control.pendingFailures.join(', ')}.` + : ''; + setMessage(`Applied ${action.replaceAll('_', ' ')}.${pending}`); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Mock control failed.'); + } finally { + setBusy(false); + } + } + + return ( +
+ Mock controls +

UI-only simulation. Real Dex durability is verified by make check.

+
+ {!flow && } + {flow && } + {flow && } + {flow && } + {flow && } + +
+ {message} +
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index ed3b67f..1828025 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,6 +5,6 @@ import './styles.css'; createRoot(document.getElementById('root')!).render( - + , ); diff --git a/web/src/styles.css b/web/src/styles.css index 2768ad1..b2f0dd7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -13,6 +13,15 @@ label { display: block; margin-bottom: 10px; font-size: .82rem; font-weight: 700 input { flex: 1; min-width: 0; padding: 14px 16px; border: 1px solid #36506d; border-radius: 12px; color: white; background: #091727; font: inherit; } button { padding: 13px 18px; border: 0; border-radius: 12px; background: #6ae4c4; color: #06131f; font: inherit; font-weight: 800; cursor: pointer; } button:disabled { opacity: .45; cursor: wait; } +button.secondary { border: 1px solid #48627e; color: #dbe7f5; background: #10243a; } +.mock-controls { margin-bottom: 36px; padding: 18px 20px; border: 1px dashed #5e83a8; border-radius: 16px; background: rgba(17, 42, 67, .9); } +.mock-controls summary { color: #6ae4c4; font-weight: 800; cursor: pointer; } +.mock-controls p, .mock-controls output { color: #aab8cc; font-size: .82rem; } +.mock-controls code { padding: 2px 5px; border-radius: 5px; background: #07111f; } +.mock-control-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; } +.mock-control-actions button { padding: 9px 12px; font-size: .78rem; } +.refresh-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; color: #ff9d9d; } +.refresh-error button { flex: none; } .status { padding: 7px 10px; border-radius: 999px; color: #6ae4c4; background: #11372f; font-size: .75rem; text-transform: uppercase; } .timeline { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 28px 0; padding: 0; list-style: none; } .timeline li { min-height: 94px; padding: 12px; border: 1px solid #253b54; border-radius: 12px; color: #6d7c90; } @@ -21,4 +30,4 @@ button:disabled { opacity: .45; cursor: wait; } .timeline strong { font-size: .8rem; } .result { padding: 14px; border-radius: 12px; color: #06131f; background: #6ae4c4; font-weight: 800; } .error { color: #ff9d9d; } -@media (max-width: 760px) { .timeline { grid-template-columns: repeat(2, 1fr); } .form-row { align-items: stretch; flex-direction: column; } } +@media (max-width: 760px) { .timeline { grid-template-columns: repeat(2, 1fr); } .form-row, .refresh-error { align-items: stretch; flex-direction: column; } } diff --git a/web/tsconfig.json b/web/tsconfig.json index b704f5b..b514e63 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,7 +15,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] }, "include": ["src", "vite.config.ts", "playwright.config.ts", "e2e"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 1117b23..ba1ec7c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,11 +1,20 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config'; -export default defineConfig({ - plugins: [react()], - test: { - environment: 'jsdom', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - setupFiles: './src/test/setup.ts', - }, +export default defineConfig(() => { + const mockTarget = process.env.VITE_MOCK_API_TARGET; + return { + plugins: [react()], + server: mockTarget ? { + proxy: { + '/api': { target: mockTarget }, + '/__mock__': { target: mockTarget }, + }, + } : undefined, + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + setupFiles: './src/test/setup.ts', + }, + }; });