From 519538fa74b6b02a85fbd091eeb05aa481a05601 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:05:59 -0800 Subject: [PATCH 01/13] feat(wire): add a switch between an app emulator and a vendor service The demo must show that only the base URL changes between an app emulator and the real vendor. A dry run builds and records the call and sends nothing, and a live call carries no Authorization header, so the demo never needs or touches a credential. --- shared/wire/proxy.go | 78 ++++++++++++++---- shared/wire/proxy_test.go | 86 ++++++++++++++++++++ shared/wire/recorder.go | 3 +- shared/wire/switch.go | 162 +++++++++++++++++++++++++++++++++++++ shared/wire/switch_test.go | 88 ++++++++++++++++++++ 5 files changed, 400 insertions(+), 17 deletions(-) create mode 100644 shared/wire/switch.go create mode 100644 shared/wire/switch_test.go diff --git a/shared/wire/proxy.go b/shared/wire/proxy.go index 0389618..4ac1629 100644 --- a/shared/wire/proxy.go +++ b/shared/wire/proxy.go @@ -3,6 +3,7 @@ package wire import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -30,6 +31,7 @@ const ( const ( SourceLocalStack = "localstack" // LocalStack's gateway serves it SourceLocal = "local" // you run it yourself, such as a binary built from WonderTwin-AI/wondertwin + SourceService = "service" // the vendor's own service, reached by changing the base URL ) // Who produced a response. These values are provisional: they name the two @@ -37,6 +39,7 @@ const ( const ( OriginEmulator = "emulator" // an app emulator, wherever it runs OriginVendor = "vendor" // the vendor's own service or sandbox + OriginBlocked = "blocked" // a dry run built the request for the vendor's service and did not send it ) // Target is where one vendor's calls go. @@ -46,11 +49,28 @@ type Target struct { Upstream string // base URL dialled, such as http://127.0.0.1:4566 Host string // Host header sent upstream; empty keeps the proxy's own + // DryRun builds each request and records it, then refuses to send it. It + // is how the demo shows a vendor's real base URL without calling the + // vendor. A dry-run call is recorded with OriginBlocked. + DryRun bool + // Invalid is why the target cannot be called, such as a base URL that does + // not parse. Every call fails with it, and nothing is sent. + Invalid string + Origin string // OriginEmulator or OriginVendor; empty means OriginEmulator EmulatorVersion string // release id of the app emulator, if known ArtifactVersion string // SDK or API version the calls were written against, if known } +// BaseURL is where the target's calls are addressed, as a person would write +// it: the host LocalStack serves the app emulator on, or the upstream. +func (t Target) BaseURL() string { + if t.Host != "" { + return "http://" + t.Host + } + return t.Upstream +} + // origin is Origin, with the default filled in. func (t Target) origin() string { if t.Origin == "" { @@ -94,15 +114,21 @@ func TargetFromEnv(getenv func(string) string, vendor string) Target { // Proxy forwards each request to a Target and records it. The whole request // and response are buffered, so it suits API calls and not streaming. type Proxy struct { - target Target + target func() Target rec *Recorder client *http.Client } // NewProxy returns a handler that forwards to t and records into rec. func NewProxy(t Target, rec *Recorder) *Proxy { + return NewSwitchedProxy(func() Target { return t }, rec) +} + +// NewSwitchedProxy is NewProxy for a target that can change between calls. It +// asks for the target afresh on every request. +func NewSwitchedProxy(target func() Target, rec *Recorder) *Proxy { return &Proxy{ - target: t, + target: target, rec: rec, client: &http.Client{ Timeout: 60 * time.Second, @@ -114,44 +140,64 @@ func NewProxy(t Target, rec *Recorder) *Proxy { func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { start := time.Now() + t := p.target() ev := Event{ Time: start, - Vendor: p.target.Vendor, - Source: p.target.Source, - Origin: p.target.origin(), - EmulatorVersion: p.target.EmulatorVersion, - ArtifactVersion: p.target.ArtifactVersion, + Vendor: t.Vendor, + Source: t.Source, + Origin: t.origin(), + Upstream: t.BaseURL(), + EmulatorVersion: t.EmulatorVersion, + ArtifactVersion: t.ArtifactVersion, Method: r.Method, Path: r.URL.RequestURI(), } reqBody, err := io.ReadAll(r.Body) if err != nil { - p.fail(w, ev, start, http.StatusBadRequest, fmt.Errorf("read request: %w", err)) + p.fail(w, ev, t, start, http.StatusBadRequest, fmt.Errorf("read request: %w", err)) return } ev.Request = capture(r.Header.Get("Content-Type"), reqBody) - out, err := http.NewRequestWithContext(r.Context(), r.Method, p.target.Upstream+r.URL.RequestURI(), bytes.NewReader(reqBody)) + if t.Invalid != "" { + p.fail(w, ev, t, start, http.StatusBadGateway, errors.New(t.Invalid)) + return + } + if t.DryRun { + ev.Origin = OriginBlocked + p.fail(w, ev, t, start, http.StatusServiceUnavailable, fmt.Errorf( + "dry run: this %s %s was built for %s and was not sent. Switch the run to Live to send it, or use the emulator", + r.Method, r.URL.Path, t.BaseURL())) + return + } + + out, err := http.NewRequestWithContext(r.Context(), r.Method, t.Upstream+r.URL.RequestURI(), bytes.NewReader(reqBody)) if err != nil { - p.fail(w, ev, start, http.StatusBadGateway, err) + p.fail(w, ev, t, start, http.StatusBadGateway, err) return } out.Header = r.Header.Clone() out.Header.Del(hopByHopConnection) - if p.target.Host != "" { - out.Host = p.target.Host + if t.Source == SourceService { + // The demo holds no credential for a vendor's service. Whatever it + // signs requests with is a made-up value for the app emulator. + out.Header.Del("Authorization") + } + if t.Host != "" { + out.Host = t.Host } resp, err := p.client.Do(out) if err != nil { - p.fail(w, ev, start, http.StatusBadGateway, err) + p.fail(w, ev, t, start, http.StatusBadGateway, fmt.Errorf( + "cannot reach %s at %s: %w. Check the URL in the settings, or switch it back to the emulator", t.Vendor, t.BaseURL(), err)) return } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - p.fail(w, ev, start, http.StatusBadGateway, fmt.Errorf("read response: %w", err)) + p.fail(w, ev, t, start, http.StatusBadGateway, fmt.Errorf("read response: %w", err)) return } @@ -173,7 +219,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // fail answers the caller with a JSON error and records the failed call. -func (p *Proxy) fail(w http.ResponseWriter, ev Event, start time.Time, status int, err error) { +func (p *Proxy) fail(w http.ResponseWriter, ev Event, t Target, start time.Time, status int, err error) { ev.Status = status ev.Duration = time.Since(start) ev.Error = err.Error() @@ -183,7 +229,7 @@ func (p *Proxy) fail(w http.ResponseWriter, ev Event, start time.Time, status in w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{ "error": "wire: " + strings.ToLower(http.StatusText(status)), - "vendor": p.target.Vendor, + "vendor": t.Vendor, "detail": err.Error(), }) } diff --git a/shared/wire/proxy_test.go b/shared/wire/proxy_test.go index b113d79..37f8fa3 100644 --- a/shared/wire/proxy_test.go +++ b/shared/wire/proxy_test.go @@ -283,3 +283,89 @@ func TestTargetFromEnvReadsTheEmulatorVersion(t *testing.T) { t.Fatalf("an unset version is unknown, not guessed: %+v", got) } } + +func TestProxyDryRunSendsNothingAndRecordsABlockedCall(t *testing.T) { + called := false + up, _ := upstream(t, func(http.ResponseWriter, *http.Request) { called = true }) + rec := NewRecorder(10) + px := httptest.NewServer(NewProxy(Target{Vendor: "stripe", Source: SourceService, Upstream: up.URL, Origin: OriginVendor, DryRun: true}, rec)) + defer px.Close() + + resp, err := http.Post(px.URL+"/v1/charges", "application/json", strings.NewReader(`{"amount":1}`)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if called { + t.Fatal("a dry run reached the upstream") + } + if resp.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "dry run") { + t.Fatalf("caller got %d %q", resp.StatusCode, body) + } + evs := rec.Events() + if len(evs) != 1 || evs[0].Origin != OriginBlocked || evs[0].Upstream != up.URL || evs[0].Path != "/v1/charges" || evs[0].Request.Content != `{"amount":1}` { + t.Fatalf("events = %+v", evs) + } +} + +func TestProxyInvalidTargetFailsWithItsReason(t *testing.T) { + rec := NewRecorder(10) + px := httptest.NewServer(NewProxy(Target{Vendor: "plaid", Source: SourceService, Invalid: "not a URL"}, rec)) + defer px.Close() + + resp, err := http.Post(px.URL+"/link/token/create", "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "not a URL") { + t.Fatalf("caller got %d %q", resp.StatusCode, body) + } +} + +func TestProxyLiveServiceCallCarriesNoAuthorization(t *testing.T) { + var auth string + up, _ := upstream(t, func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusUnauthorized) + }) + rec := NewRecorder(10) + px := httptest.NewServer(NewProxy(Target{Vendor: "stripe", Source: SourceService, Upstream: up.URL, Origin: OriginVendor}, rec)) + defer px.Close() + + req, _ := http.NewRequest("GET", px.URL+"/v1/balance", nil) + req.Header.Set("Authorization", "Bearer sk_test_made_up") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if auth != "" { + t.Fatalf("the service received Authorization %q", auth) + } + if evs := rec.Events(); len(evs) != 1 || evs[0].Origin != OriginVendor || evs[0].Status != http.StatusUnauthorized { + t.Fatalf("events = %+v", evs) + } +} + +func TestProxyUnreachableURLSaysWhatToDo(t *testing.T) { + up, _ := upstream(t, func(http.ResponseWriter, *http.Request) {}) + dead := up.URL + up.Close() + rec := NewRecorder(10) + px := httptest.NewServer(NewProxy(Target{Vendor: "xero", Source: SourceService, Upstream: dead, Origin: OriginVendor}, rec)) + defer px.Close() + + resp, err := http.Get(px.URL + "/api.xro/2.0/Invoices") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "cannot reach xero at "+dead) || !strings.Contains(string(body), "switch it back to the emulator") { + t.Fatalf("caller got %d %q", resp.StatusCode, body) + } +} diff --git a/shared/wire/recorder.go b/shared/wire/recorder.go index 897a105..34d8f60 100644 --- a/shared/wire/recorder.go +++ b/shared/wire/recorder.go @@ -23,12 +23,13 @@ type Event struct { ID uint64 `json:"id"` Time time.Time `json:"time"` Vendor string `json:"vendor"` - Source string `json:"source"` // "localstack" or "local": who hosts the app emulator + Source string `json:"source"` // "localstack", "local" or "service": who answers the call // Origin says who produced the response, and EmulatorVersion and // ArtifactVersion say which build of it and which SDK or API version the // call was written against. The last two are empty when not known. Together // they make a recorded call comparable with the same call made elsewhere. Origin string `json:"origin"` + Upstream string `json:"upstream,omitempty"` // the base URL the call was addressed to EmulatorVersion string `json:"emulator_version,omitempty"` ArtifactVersion string `json:"artifact_version,omitempty"` Method string `json:"method"` diff --git a/shared/wire/switch.go b/shared/wire/switch.go new file mode 100644 index 0000000..88e86ff --- /dev/null +++ b/shared/wire/switch.go @@ -0,0 +1,162 @@ +package wire + +import ( + "fmt" + "net/url" + "strings" + "sync" +) + +// Modes a Switch reports. +const ( + ModeDemo = "demo" // every vendor is at its app emulator + ModeProduction = "production" // at least one vendor is at a URL that is not its app emulator +) + +// Run styles, for a vendor that is not at its app emulator. +const ( + StyleDryRun = "dry" // build and record the request, send nothing + StyleLive = "live" // send it, with no credential +) + +// Lane is one vendor a Switch can point at its app emulator or at the +// vendor's own service. +type Lane struct { + Vendor string + Name string // how the UI writes the vendor, such as "Stripe" + Emulator Target // where the app emulator is + ServiceURL string // the vendor's own base URL +} + +// LaneState is one lane as the UI shows it. +type LaneState struct { + Vendor string `json:"vendor"` + Name string `json:"name"` + URL string `json:"url"` + EmulatorURL string `json:"emulator_url"` + ServiceURL string `json:"service_url"` + Service bool `json:"service"` // the URL is not the app emulator's + Error string `json:"error,omitempty"` // why the URL cannot be called +} + +// State is what a Switch reports. +type State struct { + Mode string `json:"mode"` + Style string `json:"style"` + Lanes []LaneState `json:"lanes"` +} + +// Update changes a Switch. A nil field is left alone. +type Update struct { + Style *string `json:"style,omitempty"` + URLs map[string]string `json:"urls,omitempty"` // vendor to base URL +} + +// Switch holds, for each vendor, the base URL the demo is calling now, and +// whether a call to a vendor's own service is sent. It is safe for concurrent +// use. The demo's flow asks it for a target on every call, so a change takes +// effect at once, with no restart. +// +// A Switch never sends a credential: a call to a vendor's service is either +// dry-run, so it is built and not sent, or live with the Authorization header +// removed. +type Switch struct { + mu sync.RWMutex + lanes []Lane + urls map[string]string + style string +} + +// NewSwitch starts every lane at its app emulator, in dry-run style. +func NewSwitch(lanes ...Lane) *Switch { + s := &Switch{lanes: lanes, urls: map[string]string{}, style: StyleDryRun} + for _, l := range lanes { + s.urls[l.Vendor] = l.Emulator.BaseURL() + } + return s +} + +func (s *Switch) lane(vendor string) (Lane, bool) { + for _, l := range s.lanes { + if l.Vendor == vendor { + return l, true + } + } + return Lane{}, false +} + +func clean(u string) string { return strings.TrimRight(strings.TrimSpace(u), "/") } + +// Check says why a base URL cannot be called, or returns "". +func Check(name, raw string) string { + raw = clean(raw) + if raw == "" { + return fmt.Sprintf("The %s URL is empty. Press Use emulator or Use service to fill it in.", name) + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Sprintf("%q is not a URL the demo can call. Start it with http:// or https://, such as https://example.com, or press Use emulator.", raw) + } + return "" +} + +// Target is where the vendor's calls go now. +func (s *Switch) Target(vendor string) Target { + s.mu.RLock() + defer s.mu.RUnlock() + l, ok := s.lane(vendor) + if !ok { + return Target{Vendor: vendor, Invalid: fmt.Sprintf("no vendor named %q", vendor)} + } + u := s.urls[vendor] + if u == l.Emulator.BaseURL() { + return l.Emulator + } + t := Target{Vendor: vendor, Source: SourceService, Upstream: u, Origin: OriginVendor, DryRun: s.style == StyleDryRun} + t.Invalid = Check(l.Name, u) + return t +} + +// State reports every lane. +func (s *Switch) State() State { + s.mu.RLock() + defer s.mu.RUnlock() + st := State{Mode: ModeDemo, Style: s.style} + for _, l := range s.lanes { + u := s.urls[l.Vendor] + ls := LaneState{ + Vendor: l.Vendor, Name: l.Name, URL: u, + EmulatorURL: l.Emulator.BaseURL(), ServiceURL: l.ServiceURL, + Service: u != l.Emulator.BaseURL(), + } + if ls.Service { + st.Mode = ModeProduction + ls.Error = Check(l.Name, u) + } + st.Lanes = append(st.Lanes, ls) + } + return st +} + +// Apply makes the change. It rejects a style or a vendor it does not know, and +// otherwise accepts any URL: one that cannot be called is reported by State and +// fails the call that uses it, so a presenter sees why. +func (s *Switch) Apply(u Update) error { + s.mu.Lock() + defer s.mu.Unlock() + if u.Style != nil && *u.Style != StyleDryRun && *u.Style != StyleLive { + return fmt.Errorf("style must be %q or %q", StyleDryRun, StyleLive) + } + for v := range u.URLs { + if _, ok := s.lane(v); !ok { + return fmt.Errorf("no vendor named %q", v) + } + } + if u.Style != nil { + s.style = *u.Style + } + for v, raw := range u.URLs { + s.urls[v] = clean(raw) + } + return nil +} diff --git a/shared/wire/switch_test.go b/shared/wire/switch_test.go new file mode 100644 index 0000000..28287f8 --- /dev/null +++ b/shared/wire/switch_test.go @@ -0,0 +1,88 @@ +package wire + +import ( + "strings" + "testing" +) + +func lanes() []Lane { + return []Lane{ + {Vendor: "stripe", Name: "Stripe", Emulator: LocalStack("stripe"), ServiceURL: "https://api.stripe.com"}, + {Vendor: "plaid", Name: "Plaid", Emulator: Direct("plaid", "http://127.0.0.1:5101"), ServiceURL: "https://production.plaid.com"}, + } +} + +func TestSwitchStartsInDemoMode(t *testing.T) { + s := NewSwitch(lanes()...) + st := s.State() + if st.Mode != ModeDemo || st.Style != StyleDryRun { + t.Fatalf("state = %+v", st) + } + if got := s.Target("stripe"); got.Source != SourceLocalStack || got.Host == "" { + t.Fatalf("stripe target = %+v", got) + } + if got := s.Target("plaid"); got.Source != SourceLocal || got.Upstream != "http://127.0.0.1:5101" { + t.Fatalf("plaid target = %+v", got) + } +} + +func TestSwitchServiceURLIsProductionAndDryRunByDefault(t *testing.T) { + s := NewSwitch(lanes()...) + if err := s.Apply(Update{URLs: map[string]string{"stripe": "https://api.stripe.com/"}}); err != nil { + t.Fatal(err) + } + if st := s.State(); st.Mode != ModeProduction || !st.Lanes[0].Service || st.Lanes[1].Service { + t.Fatalf("state = %+v", st) + } + got := s.Target("stripe") + if !got.DryRun || got.Source != SourceService || got.Upstream != "https://api.stripe.com" || got.Invalid != "" { + t.Fatalf("target = %+v", got) + } + live := StyleLive + if err := s.Apply(Update{Style: &live}); err != nil { + t.Fatal(err) + } + if got := s.Target("stripe"); got.DryRun { + t.Fatalf("live target is still a dry run: %+v", got) + } +} + +func TestSwitchBackToEmulatorIsDemoMode(t *testing.T) { + s := NewSwitch(lanes()...) + _ = s.Apply(Update{URLs: map[string]string{"stripe": "https://api.stripe.com"}}) + _ = s.Apply(Update{URLs: map[string]string{"stripe": s.State().Lanes[0].EmulatorURL}}) + if st := s.State(); st.Mode != ModeDemo { + t.Fatalf("state = %+v", st) + } + if got := s.Target("stripe"); got.Source != SourceLocalStack { + t.Fatalf("target = %+v", got) + } +} + +func TestSwitchReportsAnInvalidURLAndFailsTheCall(t *testing.T) { + for _, raw := range []string{"", " ", "stripe", "ftp://x", "http://"} { + s := NewSwitch(lanes()...) + _ = s.Apply(Update{URLs: map[string]string{"stripe": raw}}) + lane := s.State().Lanes[0] + if lane.Error == "" || !strings.Contains(lane.Error, "emulator") && !strings.Contains(lane.Error, "Use") { + t.Fatalf("%q: no helpful error: %+v", raw, lane) + } + if s.Target("stripe").Invalid == "" { + t.Fatalf("%q: target is callable", raw) + } + } +} + +func TestSwitchRejectsUnknownStyleAndVendor(t *testing.T) { + s := NewSwitch(lanes()...) + bad := "yolo" + if err := s.Apply(Update{Style: &bad}); err == nil { + t.Fatal("accepted an unknown style") + } + if err := s.Apply(Update{URLs: map[string]string{"nope": "http://x"}}); err == nil { + t.Fatal("accepted an unknown vendor") + } + if st := s.State(); st.Style != StyleDryRun { + t.Fatalf("a rejected update changed the state: %+v", st) + } +} From 25371fa350734a26a67c57acb6c2581d59c76e5e Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:07:10 -0800 Subject: [PATCH 02/13] feat(demo): let the lender's vendor calls follow a runtime switch The presenter changes a vendor's base URL while the demo runs, so each call asks the switch where its vendor is now. GET and PUT /api/targets read and change it, and an invalid URL is reported before a run, not after it fails. --- .../acme-commerce-lenders/internal/api/api.go | 35 +++++++++++++++- .../internal/api/api_test.go | 39 ++++++++++++++++++ .../acme-commerce-lenders/internal/cli/cli.go | 5 ++- .../lender/reads_test.go | 41 +++++++++++++++++++ apps/acme-commerce-lenders/lender/vendors.go | 12 ++++++ apps/acme-commerce-lenders/world/seed.go | 10 +++++ 6 files changed, 139 insertions(+), 3 deletions(-) diff --git a/apps/acme-commerce-lenders/internal/api/api.go b/apps/acme-commerce-lenders/internal/api/api.go index 833d6e7..aedd8e0 100644 --- a/apps/acme-commerce-lenders/internal/api/api.go +++ b/apps/acme-commerce-lenders/internal/api/api.go @@ -44,8 +44,18 @@ type Status struct { // New returns the API handler, to be mounted at /api/. func New(apps Applications, admin Admin, rec *wire.Recorder) http.Handler { - h := &handler{apps: apps, admin: admin, rec: rec} + return NewWithTargets(apps, admin, rec, nil) +} + +// NewWithTargets is New with the routes that read and change where each vendor's +// calls go. A nil sw leaves those routes out. +func NewWithTargets(apps Applications, admin Admin, rec *wire.Recorder, sw *wire.Switch) http.Handler { + h := &handler{apps: apps, admin: admin, rec: rec, sw: sw} mux := http.NewServeMux() + if sw != nil { + mux.HandleFunc("GET /api/targets", h.targets) + mux.HandleFunc("PUT /api/targets", h.setTargets) + } mux.HandleFunc("GET /api/status", h.status) mux.HandleFunc("POST /api/reset", h.reset) mux.HandleFunc("POST /api/applications", h.apply) @@ -67,6 +77,7 @@ type handler struct { apps Applications admin Admin rec *wire.Recorder + sw *wire.Switch // A reset wipes everything the other calls read, so it waits for them and // they wait for it. mu sync.RWMutex @@ -137,6 +148,28 @@ func (h *handler) events(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"events": evs, "last": last, "generation": h.rec.Generation()}) } +// targets says where each vendor's calls go now, and whether the demo is in +// demo mode (every vendor at its app emulator) or production mode. +func (h *handler) targets(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.sw.State()) +} + +// setTargets changes the run style or any vendor's base URL. A URL that cannot +// be called is accepted and reported in the state, so the UI can say why a run +// will fail before it starts. +func (h *handler) setTargets(w http.ResponseWriter, r *http.Request) { + var u wire.Update + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&u); err != nil { + writeError(w, http.StatusBadRequest, errors.New("the body must be JSON like {\"urls\":{\"stripe\":\"https://api.stripe.com\"},\"style\":\"dry\"}")) + return + } + if err := h.sw.Apply(u); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, h.sw.State()) +} + // statusFor maps an error to an HTTP status. A failure the lender could not // have avoided is a vendor failure, so it is a bad gateway. func statusFor(err error) int { diff --git a/apps/acme-commerce-lenders/internal/api/api_test.go b/apps/acme-commerce-lenders/internal/api/api_test.go index a8ffed7..82a3797 100644 --- a/apps/acme-commerce-lenders/internal/api/api_test.go +++ b/apps/acme-commerce-lenders/internal/api/api_test.go @@ -258,3 +258,42 @@ func TestWorldAdminReportsReadinessAndResetsTheRecorder(t *testing.T) { t.Fatalf("after a reset the world is ready: %+v, %v", st, err) } } + +func TestTargetsReportAndChangeWhereEachVendorIsCalled(t *testing.T) { + sw := wire.NewSwitch( + wire.Lane{Vendor: "stripe", Name: "Stripe", Emulator: wire.LocalStack("stripe"), ServiceURL: "https://api.stripe.com"}, + ) + h := NewWithTargets(&fakeApps{}, &fakeAdmin{}, wire.NewRecorder(1), sw) + + get := func() wire.State { + rec := do(h, "GET", "/api/targets") + var st wire.State + if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil || rec.Code != 200 { + t.Fatalf("GET /api/targets: %d %s", rec.Code, rec.Body) + } + return st + } + put := func(body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("PUT", "/api/targets", strings.NewReader(body))) + return rec + } + + if st := get(); st.Mode != wire.ModeDemo || st.Style != wire.StyleDryRun || len(st.Lanes) != 1 { + t.Fatalf("start: %+v", st) + } + if rec := put(`{"urls":{"stripe":"https://api.stripe.com"}}`); rec.Code != 200 { + t.Fatalf("PUT: %d %s", rec.Code, rec.Body) + } + if st := get(); st.Mode != wire.ModeProduction || !st.Lanes[0].Service { + t.Fatalf("after PUT: %+v", st) + } + if rec := put(`{"urls":{"stripe":"stripe"}}`); rec.Code != 200 || get().Lanes[0].Error == "" { + t.Fatalf("an invalid URL is reported in the state: %d %+v", rec.Code, get()) + } + for _, body := range []string{`{"style":"yolo"}`, `{"urls":{"nope":"http://x"}}`, `not json`} { + if rec := put(body); rec.Code != 400 { + t.Errorf("PUT %s: %d, want 400", body, rec.Code) + } + } +} diff --git a/apps/acme-commerce-lenders/internal/cli/cli.go b/apps/acme-commerce-lenders/internal/cli/cli.go index 96fcafd..0734233 100644 --- a/apps/acme-commerce-lenders/internal/cli/cli.go +++ b/apps/acme-commerce-lenders/internal/cli/cli.go @@ -99,10 +99,11 @@ func (a *app) handler() (http.Handler, error) { return nil, err } rec := wire.NewRecorder(2000) - svc := &lender.Service{Vendors: lender.NewVendors(rec, a.targets), Store: store} + sw := wire.NewSwitch(a.targets.Lanes()...) + svc := &lender.Service{Vendors: lender.NewSwitchedVendors(rec, sw), Store: store} admin := api.WorldAdmin{Targets: a.targets, HC: a.httpClient(), Store: store, Rec: rec} mux := http.NewServeMux() - mux.Handle("/api/", api.New(svc, admin, rec)) + mux.Handle("/api/", api.NewWithTargets(svc, admin, rec, sw)) mux.Handle("/", ui.Handler()) return mux, nil } diff --git a/apps/acme-commerce-lenders/lender/reads_test.go b/apps/acme-commerce-lenders/lender/reads_test.go index 458688c..7908fbd 100644 --- a/apps/acme-commerce-lenders/lender/reads_test.go +++ b/apps/acme-commerce-lenders/lender/reads_test.go @@ -390,3 +390,44 @@ func TestParseXeroDate(t *testing.T) { t.Error("an unrecognised date must be an error") } } + +func TestSwitchedVendorsFollowTheSwitchWithoutARestart(t *testing.T) { + f := newFakeVendors(t) + rec := wire.NewRecorder(10) + sw := wire.NewSwitch( + wire.Lane{Vendor: "stripe", Name: "Stripe", Emulator: wire.Direct("stripe", f.srv.URL), ServiceURL: "https://api.stripe.com"}, + wire.Lane{Vendor: "plaid", Name: "Plaid", Emulator: wire.Direct("plaid", f.srv.URL), ServiceURL: "https://production.plaid.com"}, + wire.Lane{Vendor: "xero", Name: "Xero", Emulator: wire.Direct("xero", f.srv.URL), ServiceURL: "https://api.xero.com"}, + ) + v := NewSwitchedVendors(rec, sw) + ctx := context.Background() + + if _, err := v.connectXero(ctx, facts()); err != nil { + t.Fatalf("at the emulator: %v", err) + } + + // Pointed at Xero's own service in a dry run, nothing is sent. + if err := sw.Apply(wire.Update{URLs: map[string]string{"xero": "https://api.xero.com"}}); err != nil { + t.Fatal(err) + } + _, err := v.connectXero(ctx, facts()) + if err == nil || !strings.Contains(err.Error(), "dry run") { + t.Fatalf("a dry run call: %v", err) + } + last := rec.Events()[len(rec.Events())-1] + if last.Origin != wire.OriginBlocked || last.Upstream != "https://api.xero.com" { + t.Fatalf("recorded %+v", last) + } + + // An invalid URL fails with a message that says what to do. + _ = sw.Apply(wire.Update{URLs: map[string]string{"xero": "xero"}}) + if _, err := v.connectXero(ctx, facts()); err == nil || !strings.Contains(err.Error(), "Use emulator") { + t.Fatalf("an invalid URL: %v", err) + } + + // Back at the emulator, it works again. + _ = sw.Apply(wire.Update{URLs: map[string]string{"xero": f.srv.URL}}) + if _, err := v.connectXero(ctx, facts()); err != nil { + t.Fatalf("back at the emulator: %v", err) + } +} diff --git a/apps/acme-commerce-lenders/lender/vendors.go b/apps/acme-commerce-lenders/lender/vendors.go index 834e055..7eeb86b 100644 --- a/apps/acme-commerce-lenders/lender/vendors.go +++ b/apps/acme-commerce-lenders/lender/vendors.go @@ -24,6 +24,18 @@ func NewVendors(rec *wire.Recorder, tg world.Targets) Vendors { return Vendors{Stripe: apiFor(rec, tg.Stripe), Plaid: apiFor(rec, tg.Plaid), Xero: apiFor(rec, tg.Xero)} } +// NewSwitchedVendors is NewVendors for targets that can change while the demo +// runs. Each call asks sw where its vendor is now. +func NewSwitchedVendors(rec *wire.Recorder, sw *wire.Switch) Vendors { + lane := func(vendor string) seed.API { + return seed.API{ + HC: wire.NewSwitchedProxy(func() wire.Target { return sw.Target(vendor) }, rec).Client(), + Target: wire.Target{Vendor: vendor, Upstream: "http://wire.invalid"}, + } + } + return Vendors{Stripe: lane("stripe"), Plaid: lane("plaid"), Xero: lane("xero")} +} + // apiFor returns a client whose calls the proxy records and forwards to t. The // URL host is a placeholder, because the proxy uses its own target. func apiFor(rec *wire.Recorder, t wire.Target) seed.API { diff --git a/apps/acme-commerce-lenders/world/seed.go b/apps/acme-commerce-lenders/world/seed.go index 36b926c..0fb07b0 100644 --- a/apps/acme-commerce-lenders/world/seed.go +++ b/apps/acme-commerce-lenders/world/seed.go @@ -30,6 +30,16 @@ func TargetsFromEnv(getenv func(string) string) Targets { } } +// Lanes are the three vendors as a wire.Switch can point them: at the app +// emulator the targets name, or at the vendor's own service. +func (t Targets) Lanes() []wire.Lane { + return []wire.Lane{ + {Vendor: "stripe", Name: "Stripe", Emulator: t.Stripe, ServiceURL: "https://api.stripe.com"}, + {Vendor: "plaid", Name: "Plaid", Emulator: t.Plaid, ServiceURL: "https://production.plaid.com"}, + {Vendor: "xero", Name: "Xero", Emulator: t.Xero, ServiceURL: "https://api.xero.com"}, + } +} + // All lists the targets in a fixed order. func (t Targets) All() []wire.Target { return []wire.Target{t.Stripe, t.Plaid, t.Xero} } From a5ea66e062519aff9940135f7a6d3883f6eaae52 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:11:37 -0800 Subject: [PATCH 03/13] fix(seed): show the proxy's reason when a call was not sent A dry run or an unreachable URL reached the UI as raw JSON. The wire proxy puts the reason in detail, so the error is now that sentence. --- shared/seed/api.go | 9 +++++++++ shared/seed/error_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 shared/seed/error_test.go diff --git a/shared/seed/api.go b/shared/seed/api.go index 9a4e0ac..7936869 100644 --- a/shared/seed/api.go +++ b/shared/seed/api.go @@ -36,6 +36,15 @@ type Error struct { } func (e *Error) Error() string { + // The wire proxy answers for a call it did not send, or could not make, + // with the reason in "detail". That reason is the message. + var w struct { + Error string `json:"error"` + Detail string `json:"detail"` + } + if json.Unmarshal([]byte(e.Body), &w) == nil && strings.HasPrefix(w.Error, "wire: ") && w.Detail != "" { + return w.Detail + } return fmt.Sprintf("%s %s %s: HTTP %d: %s", e.Vendor, e.Method, e.Path, e.Status, e.Body) } diff --git a/shared/seed/error_test.go b/shared/seed/error_test.go new file mode 100644 index 0000000..fb101ef --- /dev/null +++ b/shared/seed/error_test.go @@ -0,0 +1,24 @@ +package seed + +import ( + "strings" + "testing" +) + +func TestErrorUsesTheProxysDetailAndKeepsAVendorsOwnBody(t *testing.T) { + proxy := &Error{Vendor: "stripe", Method: "GET", Path: "/v1/balance", Status: 503, + Body: `{"detail":"dry run: this GET /v1/balance was not sent","error":"wire: service unavailable","vendor":"stripe"}`} + if got := proxy.Error(); got != "dry run: this GET /v1/balance was not sent" { + t.Errorf("proxy error = %q", got) + } + + vendor := &Error{Vendor: "stripe", Method: "GET", Path: "/v1/balance", Status: 401, Body: `{"error":{"message":"No API key provided"}}`} + if got := vendor.Error(); !strings.HasPrefix(got, "stripe GET /v1/balance: HTTP 401: ") || !strings.Contains(got, "No API key provided") { + t.Errorf("vendor error = %q", got) + } + + plain := &Error{Vendor: "xero", Method: "GET", Path: "/x", Status: 500, Body: "boom"} + if got := plain.Error(); got != "xero GET /x: HTTP 500: boom" { + t.Errorf("plain error = %q", got) + } +} From a743fc1e10c27846835838fa4868b2eb65863c74 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:11:37 -0800 Subject: [PATCH 04/13] feat(demo): add a mode banner, base URL overlay and demo switcher The presenter shows the mechanism by changing a vendor's base URL from the emulator to its service. The non-dismissable banner says which mode runs, the gear overlay edits each URL, and the inspector marks a dry run as not sent. --- .../acme-commerce-lenders/src/api/client.ts | 36 +++- .../acme-commerce-lenders/src/api/queries.ts | 10 ++ .../acme-commerce-lenders/src/api/types.ts | 32 +++- .../src/components/demo-switcher.tsx | 43 +++++ .../src/components/header.tsx | 2 + .../src/components/mode-banner.test.tsx | 170 ++++++++++++++++++ .../src/components/mode-banner.tsx | 108 +++++++++++ .../src/components/targets-dialog.tsx | 106 +++++++++++ .../src/demo/demo-page.test.tsx | 53 ++++++ .../src/demo/demo-page.tsx | 17 +- .../src/inspector/call-row.tsx | 18 +- .../src/inspector/inspector-page.test.tsx | 20 +++ 12 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx create mode 100644 web/apps/acme-commerce-lenders/src/components/mode-banner.test.tsx create mode 100644 web/apps/acme-commerce-lenders/src/components/mode-banner.tsx create mode 100644 web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx diff --git a/web/apps/acme-commerce-lenders/src/api/client.ts b/web/apps/acme-commerce-lenders/src/api/client.ts index b4e681a..8f52049 100644 --- a/web/apps/acme-commerce-lenders/src/api/client.ts +++ b/web/apps/acme-commerce-lenders/src/api/client.ts @@ -1,4 +1,11 @@ -import type { Application, EventsResponse, Facts, Status } from './types' +import type { + Application, + EventsResponse, + Facts, + Status, + Targets, + TargetsUpdate, +} from './types' /** An error answer from the demo's API. */ export class ApiError extends Error { @@ -11,17 +18,29 @@ export class ApiError extends Error { } } -async function request(method: 'GET' | 'POST', path: string): Promise { - const res = await fetch(`/api${path}`, { method }) - const body: unknown = await res.json().catch(() => null) +async function request( + method: 'GET' | 'POST' | 'PUT', + path: string, + body?: unknown, +): Promise { + const res = await fetch(`/api${path}`, { + method, + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + }) + const answer: unknown = await res.json().catch(() => null) if (!res.ok) { const message = - body && typeof body === 'object' && 'error' in body - ? String((body as { error: unknown }).error) + answer && typeof answer === 'object' && 'error' in answer + ? String((answer as { error: unknown }).error) : res.statusText throw new ApiError(message || `HTTP ${res.status}`, res.status) } - return body as T + return answer as T } const step = (name: string) => (id: string) => @@ -29,6 +48,9 @@ const step = (name: string) => (id: string) => export const api = { getStatus: () => request('GET', '/status'), + getTargets: () => request('GET', '/targets'), + setTargets: (update: TargetsUpdate) => + request('PUT', '/targets', update), getEvents: (since: number) => request('GET', `/events?since=${since}`), reset: () => request('POST', '/reset'), diff --git a/web/apps/acme-commerce-lenders/src/api/queries.ts b/web/apps/acme-commerce-lenders/src/api/queries.ts index bf9c62d..b544365 100644 --- a/web/apps/acme-commerce-lenders/src/api/queries.ts +++ b/web/apps/acme-commerce-lenders/src/api/queries.ts @@ -10,3 +10,13 @@ export const statusQueries = { refetchInterval: 5000, }), } + +export const targetQueries = { + // Polled, so a second tab or a restart shows the current mode. + current: () => + queryOptions({ + queryKey: ['targets'], + queryFn: api.getTargets, + refetchInterval: 5000, + }), +} diff --git a/web/apps/acme-commerce-lenders/src/api/types.ts b/web/apps/acme-commerce-lenders/src/api/types.ts index c85fe33..b61080f 100644 --- a/web/apps/acme-commerce-lenders/src/api/types.ts +++ b/web/apps/acme-commerce-lenders/src/api/types.ts @@ -105,7 +105,11 @@ export interface WireEvent { id: number time: string vendor: string - source: 'localstack' | 'local' + source: 'localstack' | 'local' | 'service' + /** Who produced the response. `blocked` is a dry run: built, not sent. */ + origin: 'emulator' | 'vendor' | 'blocked' + /** The base URL the call was addressed to. */ + upstream?: string method: string path: string status: number @@ -121,3 +125,29 @@ export interface EventsResponse { /** Changes when a reset clears the recording. */ generation: number } + +export type RunStyle = 'dry' | 'live' + +/** One vendor, as the switch holds it. */ +export interface Lane { + vendor: string + name: string + url: string + emulator_url: string + service_url: string + /** The URL is not the app emulator's. */ + service: boolean + /** Why the URL cannot be called. */ + error?: string +} + +export interface Targets { + mode: 'demo' | 'production' + style: RunStyle + lanes: Lane[] +} + +export interface TargetsUpdate { + style?: RunStyle + urls?: Record +} diff --git a/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx b/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx new file mode 100644 index 0000000..898a01e --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx @@ -0,0 +1,43 @@ +import { Check, ChevronDown } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' + +/** The demos in this repo. There is one, so the menu has one entry. */ +const demos = [{ id: 'acme-commerce-lenders', name: 'ACME Commerce Lenders' }] + +export function DemoSwitcher({ current }: { current: string }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open && ( +
+ {demos.map((d) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/apps/acme-commerce-lenders/src/components/header.tsx b/web/apps/acme-commerce-lenders/src/components/header.tsx index 4be711f..2d7e831 100644 --- a/web/apps/acme-commerce-lenders/src/components/header.tsx +++ b/web/apps/acme-commerce-lenders/src/components/header.tsx @@ -1,5 +1,6 @@ import logo from '@localstack-app-emulators/brand/logo/localstack-logo-horizontal-light.svg' import { Eye, Info, RotateCcw } from 'lucide-react' +import { DemoSwitcher } from '@/components/demo-switcher' import { Button, buttonVariants } from '@/components/ui/button' export function Header({ @@ -27,6 +28,7 @@ export function Header({
+ ({ getTargets: vi.fn(), setTargets: vi.fn() })) + +vi.mock('@/api/client', async () => { + const actual = + await vi.importActual('@/api/client') + return { ...actual, api: { ...mocks } } +}) + +const lane = (vendor: string, name: string, url: string, service = false) => ({ + vendor, + name, + url, + emulator_url: `http://${vendor}.emulator`, + service_url: `https://api.${vendor}.com`, + service, +}) + +const demo: Targets = { + mode: 'demo', + style: 'dry', + lanes: [ + lane('stripe', 'Stripe', 'http://stripe.emulator'), + lane('xero', 'Xero', 'http://xero.emulator'), + ], +} + +const production: Targets = { + mode: 'production', + style: 'dry', + lanes: [ + lane('stripe', 'Stripe', 'https://api.stripe.com', true), + lane('xero', 'Xero', 'http://xero.emulator'), + ], +} + +function renderBanner() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('ModeBanner', () => { + it('says Demo Mode when every vendor is at its app emulator', async () => { + mocks.getTargets.mockResolvedValue(demo) + renderBanner() + expect(await screen.findByText('Demo Mode')).toBeInTheDocument() + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument() + }) + + it('says Production Mode, with the run style, when a vendor is at its service', async () => { + mocks.getTargets.mockResolvedValue(production) + renderBanner() + expect(await screen.findByText('Production Mode')).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Dry run' })).toBeChecked() + expect(screen.getByRole('status')).toHaveTextContent(/nothing is sent/i) + }) + + it('switches to Live and says requests are sent without a credential', async () => { + mocks.getTargets.mockResolvedValue(production) + mocks.setTargets.mockResolvedValue({ ...production, style: 'live' }) + renderBanner() + await userEvent.click(await screen.findByRole('radio', { name: 'Live' })) + expect(mocks.setTargets).toHaveBeenCalledWith({ style: 'live' }) + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent(/no credential/i), + ) + }) + + it('cannot be dismissed', async () => { + mocks.getTargets.mockResolvedValue(demo) + renderBanner() + await screen.findByText('Demo Mode') + expect( + screen.queryByRole('button', { name: /dismiss|close/i }), + ).not.toBeInTheDocument() + }) + + it('shows why a base URL cannot be called', async () => { + mocks.getTargets.mockResolvedValue({ + ...production, + lanes: [ + { + ...production.lanes[0], + url: 'stripe', + error: '"stripe" is not a URL the demo can call.', + }, + production.lanes[1], + ], + }) + renderBanner() + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Stripe: "stripe" is not a URL the demo can call.', + ) + }) +}) + +describe('the base URL overlay', () => { + async function open() { + mocks.getTargets.mockResolvedValue(production) + renderBanner() + await userEvent.click( + await screen.findByRole('button', { name: 'Change base URLs' }), + ) + return screen.getByRole('dialog', { name: 'Base URLs' }) + } + + it('fills a field from Use emulator and Use service', async () => { + const dialog = await open() + const stripe = within(dialog).getByLabelText('Stripe') + expect(stripe).toHaveValue('https://api.stripe.com') + + await userEvent.clear(stripe) + expect(stripe).toHaveValue('') + + await userEvent.click( + within(dialog).getAllByRole('button', { name: 'Use emulator' })[0], + ) + expect(stripe).toHaveValue('http://stripe.emulator') + + await userEvent.click( + within(dialog).getAllByRole('button', { name: 'Use service' })[0], + ) + expect(stripe).toHaveValue('https://api.stripe.com') + }) + + it('saves every field and closes', async () => { + const dialog = await open() + mocks.setTargets.mockResolvedValue(demo) + await userEvent.click( + within(dialog).getAllByRole('button', { name: 'Use emulator' })[0], + ) + await userEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(mocks.setTargets).toHaveBeenCalledWith({ + urls: { + stripe: 'http://stripe.emulator', + xero: 'http://xero.emulator', + }, + }), + ) + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ) + }) + + it('says what the API refused', async () => { + const dialog = await open() + mocks.setTargets.mockRejectedValue(new Error('no vendor named "nope"')) + await userEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'no vendor named "nope"', + ) + }) +}) diff --git a/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx b/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx new file mode 100644 index 0000000..489f8e8 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx @@ -0,0 +1,108 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Settings } from 'lucide-react' +import { useState } from 'react' +import { api } from '@/api/client' +import { targetQueries } from '@/api/queries' +import type { RunStyle, Targets } from '@/api/types' +import { TargetsDialog } from '@/components/targets-dialog' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +const styleNote: Record = { + dry: 'Dry run: each request is built and shown in the inspector, and nothing is sent.', + live: 'Live: each request is sent to the vendor with no credential, so expect the vendor to refuse it.', +} + +/** + * Says which mode the demo is in, and cannot be dismissed. Demo mode calls the + * app emulators. Production mode addresses the vendors' own services, to show + * that only the base URL changed. + */ +export function ModeBanner() { + const { data } = useQuery(targetQueries.current()) + const [open, setOpen] = useState(false) + const queryClient = useQueryClient() + const setStyle = useMutation({ + mutationFn: (style: RunStyle) => api.setTargets({ style }), + onSuccess: (t: Targets) => queryClient.setQueryData(['targets'], t), + }) + + if (!data) return null + const production = data.mode === 'production' + const problems = data.lanes.filter((l) => l.error) + + return ( +
+
+
+ + {production ? 'Production Mode' : 'Demo Mode'} + + + {production + ? 'Calls are addressed to the vendors’ own services.' + : 'Every call goes to an app emulator.'} + +
+
+ {production && ( +
+ {(['dry', 'live'] as const).map((s) => ( + + ))} +
+ )} + +
+
+ {production && ( +

+ {styleNote[data.style]} +

+ )} + {problems.map((l) => ( +

+ {l.name}: {l.error} +

+ ))} + {open && setOpen(false)} />} +
+ ) +} diff --git a/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx b/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx new file mode 100644 index 0000000..371e154 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx @@ -0,0 +1,106 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { X } from 'lucide-react' +import { useState } from 'react' +import { api } from '@/api/client' +import type { Targets } from '@/api/types' +import { Button } from '@/components/ui/button' + +/** + * The base URL each vendor is called at. Use emulator and Use service fill the + * field, so a deleted URL is easy to put back. Saving never needs a credential. + */ +export function TargetsDialog({ + targets, + onClose, +}: { + targets: Targets + onClose: () => void +}) { + const [urls, setUrls] = useState(() => + Object.fromEntries(targets.lanes.map((l) => [l.vendor, l.url])), + ) + const queryClient = useQueryClient() + const save = useMutation({ + mutationFn: () => api.setTargets({ urls }), + onSuccess: (t) => { + queryClient.setQueryData(['targets'], t) + onClose() + }, + }) + + return ( +
+
+
+
+

+ Base URLs +

+

+ The lender calls each vendor at the URL below. Change it and the + next call goes there. Nothing else in the app changes. +

+
+ +
+ + {targets.lanes.map((l) => ( +
+ +
+ + setUrls({ ...urls, [l.vendor]: e.target.value }) + } + spellCheck={false} + className="min-w-0 flex-1 rounded-md border bg-background px-3 py-2 font-mono text-sm" + /> + + +
+
+ ))} + + {save.error && ( +

+ {save.error.message} +

+ )} +
+ + +
+
+
+ ) +} diff --git a/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx b/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx index a813f62..b397330 100644 --- a/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx +++ b/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx @@ -7,6 +7,7 @@ import { DemoPage } from './demo-page' const mocks = vi.hoisted(() => ({ getStatus: vi.fn(), + getTargets: vi.fn(), reset: vi.fn(), apply: vi.fn(), connect: vi.fn(), @@ -366,3 +367,55 @@ describe('DemoPage', () => { ).toBeInTheDocument() }) }) + +describe('DemoPage in production mode', () => { + const lanes = (error?: string) => [ + { + vendor: 'stripe', + name: 'Stripe', + url: error ? 'stripe' : 'https://api.stripe.com', + emulator_url: 'http://stripe.emulator', + service_url: 'https://api.stripe.com', + service: true, + ...(error ? { error } : {}), + }, + ] + + it('lets Apply run without the app emulators ready, because they are not what it reads', async () => { + mocks.getStatus.mockResolvedValue({ + ...readyStatus, + ready: false, + seeded: false, + }) + mocks.getTargets.mockResolvedValue({ + mode: 'production', + style: 'dry', + lanes: lanes(), + }) + renderPage() + const apply = await screen.findByRole('button', { + name: 'Apply for working capital', + }) + await waitFor(() => expect(apply).toBeEnabled()) + expect(screen.queryByTestId('emulator-stripe')).not.toBeInTheDocument() + }) + + it('holds Apply back, and says to fix the URL, when a base URL cannot be called', async () => { + mocks.getTargets.mockResolvedValue({ + mode: 'production', + style: 'dry', + lanes: lanes('"stripe" is not a URL the demo can call.'), + }) + renderPage() + expect( + await screen.findByText('Fix the base URL above to run the demo.'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Apply for working capital' }), + ).toBeDisabled() + expect( + screen.getByText('Fix the base URL above to run the demo.'), + ).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('is not a URL') + }) +}) diff --git a/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx b/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx index c676d98..725f47e 100644 --- a/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx +++ b/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx @@ -1,7 +1,8 @@ import { useQuery } from '@tanstack/react-query' -import { statusQueries } from '@/api/queries' +import { statusQueries, targetQueries } from '@/api/queries' import { FundingCard } from '@/components/funding-card' import { Header } from '@/components/header' +import { ModeBanner } from '@/components/mode-banner' import { OfferCard } from '@/components/offer-card' import { Readiness } from '@/components/readiness' import { StepList } from '@/components/step-list' @@ -15,7 +16,12 @@ import { acceptSteps, applySteps, useDemoFlow } from './use-demo-flow' export function DemoPage() { const flow = useDemoFlow() const status = useQuery(statusQueries.current()) - const ready = status.data?.ready === true + const targets = useQuery(targetQueries.current()) + const production = targets.data?.mode === 'production' + const badUrl = targets.data?.lanes.some((l) => l.error) === true + // In production mode the app emulators and the seeded world are not what the + // run reads, so only a URL that cannot be called holds it back. + const ready = production ? !badUrl : status.data?.ready === true const merchant = status.data?.facts?.merchant ?? 'The merchant' const app = flow.application @@ -28,6 +34,7 @@ export function DemoPage() { return (
+ {flow.resetError && ( @@ -35,7 +42,7 @@ export function DemoPage() { )} - + {!production && } {merchant} @@ -61,7 +68,9 @@ export function DemoPage() { {!ready && ( - Waiting for the app emulators and the seeded world. + {badUrl + ? 'Fix the base URL above to run the demo.' + : 'Waiting for the app emulators and the seeded world.'} )}
diff --git a/web/apps/acme-commerce-lenders/src/inspector/call-row.tsx b/web/apps/acme-commerce-lenders/src/inspector/call-row.tsx index c7e43aa..ad5ea36 100644 --- a/web/apps/acme-commerce-lenders/src/inspector/call-row.tsx +++ b/web/apps/acme-commerce-lenders/src/inspector/call-row.tsx @@ -11,6 +11,12 @@ const vendorNames: Record = { xero: 'Xero', } +const sourceName: Record = { + localstack: 'LocalStack', + local: 'Local', + service: 'Vendor service', +} + export const vendorName = (vendor: string) => vendorNames[vendor] ?? vendor function ms(ns: number) { @@ -44,7 +50,8 @@ function Body({ title, body }: { title: string; body: WireBody }) { /** One call to a vendor: what it was for, then the call, then its bodies. */ export function CallRow({ event }: { event: WireEvent }) { const [open, setOpen] = useState(false) - const failed = event.error !== undefined || event.status >= 400 + const blocked = event.origin === 'blocked' + const failed = !blocked && (event.error !== undefined || event.status >= 400) const when = new Date(event.time).toLocaleTimeString() return ( @@ -64,15 +71,16 @@ export function CallRow({ event }: { event: WireEvent }) {
{caption(event)}
- {event.method} {event.path} + {event.method} {event.upstream ?? ''} + {event.path}
{vendorName(event.vendor)} - - {event.source === 'localstack' ? 'LocalStack' : 'Local'} + + {blocked ? 'Dry run, not sent' : sourceName[event.source]} - {event.error ? 'No answer' : event.status} + {blocked ? 'Not sent' : event.error ? 'No answer' : event.status} {ms(event.duration_ns)} diff --git a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx index 4227dda..3e49588 100644 --- a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx +++ b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx @@ -25,6 +25,7 @@ function event(id: number, over: Partial = {}): WireEvent { time: '2026-11-04T10:00:00Z', vendor: 'stripe', source: 'localstack', + origin: 'emulator', method: 'GET', path: '/v1/charges?limit=100', status: 200, @@ -80,6 +81,25 @@ describe('InspectorPage', () => { expect(within(rows[1]).getByText('LocalStack')).toBeTruthy() }) + it('marks a dry-run call as not sent, with the host it was built for', async () => { + mocks.getEvents.mockResolvedValue( + answer([ + event(1, { + source: 'service', + origin: 'blocked', + upstream: 'https://api.stripe.com', + status: 503, + error: 'dry run: this GET /v1/charges was built and not sent', + }), + ]), + ) + render() + const row = (await screen.findAllByTestId('call'))[0] + expect(within(row).getByText('Dry run, not sent')).toBeTruthy() + expect(within(row).getByText('Not sent')).toBeTruthy() + expect(within(row).getByText(/https:\/\/api\.stripe\.com/)).toBeTruthy() + }) + it('asks only for what is new, and adds it to the list', async () => { mocks.getEvents .mockResolvedValueOnce(answer([event(1)])) From dbc273b2d3dfc2954ca110dd3b331299dc659ce2 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:11:37 -0800 Subject: [PATCH 05/13] docs(demo): explain Demo Mode, Production Mode and the run styles Production Mode must say plainly what is and is not sent, since Live puts real traffic on the network. --- apps/acme-commerce-lenders/docs/usage.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/acme-commerce-lenders/docs/usage.md b/apps/acme-commerce-lenders/docs/usage.md index c10a4d0..ee9f0dd 100644 --- a/apps/acme-commerce-lenders/docs/usage.md +++ b/apps/acme-commerce-lenders/docs/usage.md @@ -59,7 +59,7 @@ A vendor with no `_EMULATOR_URL` set uses LocalStack. When your LocalSta Beside the vendor, method, path and bodies, each call the inspector shows records which app emulator answered it and at which release: -- **`origin`** is who produced the response. It is `emulator` for every call the demo makes. +- **`origin`** is who produced the response. It is `emulator` for every call the demo makes in Demo Mode. In Production Mode it is `blocked` for a dry run, which was built and not sent, and `vendor` for a live call. `upstream` is the base URL the call was addressed to. - **`emulator_version`** is the release id of the app emulator, such as `0123456789ab`. An app emulator does not announce its release, because the id is derived from the built binaries. Set `_EMULATOR_VERSION` (for example `PLAID_EMULATOR_VERSION`) to the id of the release you installed, which is in its tag, `twin--v`. Unset means unknown, and the field is left out. - **`artifact_version`** is the SDK or API version the calls were written against. The demo calls the vendors over plain HTTP and sets none, so it is left out. @@ -142,6 +142,8 @@ For the seeded merchant the offer is an advance of **$30,000** for a flat 8% fee | `POST /api/applications` | Starts an application | | `POST /api/applications/{id}/connect`, `review`, `offer`, `accept`, `fund`, `settle` | The steps, which must happen in that order. A declined offer cannot be accepted. | | `GET /api/applications/{id}` | The application so far | +| `GET /api/targets` | The base URL each vendor is called at, the mode (`demo` or `production`) and the run style (`dry` or `live`) | +| `PUT /api/targets` | Changes them: `{"urls":{"stripe":"https://api.stripe.com"},"style":"live"}`. A URL that cannot be called is reported in the state and fails the call that uses it. | | `GET /api/events?since=N` | Every vendor call made so far, newest after `N`, with request and response bodies. Credentials are masked. | Plaid's balance is shown with the offer but does not change it, because the Plaid app emulator's data is too thin to price against. @@ -185,6 +187,24 @@ The inspector shows the calls the lender makes while the demo runs. The calls th The binary keeps the most recent 2,000 calls. Reading a merchant's sales is paged, so one run makes dozens of calls to Stripe. +## Demo Mode and Production Mode + +A banner under the header says which mode the demo is in. It cannot be dismissed. + +- **Demo Mode** is the default. Every vendor is called at its app emulator. +- **Production Mode** starts when any vendor's base URL is changed to something other than its app emulator. Open the gear in the banner, edit a vendor's URL, or press **Use service** to fill in the vendor's own base URL (`https://api.stripe.com`, `https://production.plaid.com`, `https://api.xero.com`). **Use emulator** puts the app emulator's URL back, so a URL deleted by mistake is one click to restore. + +The point is to show the mechanism: the lender's code is the same in both modes and only the base URL changes. The demo never holds a credential for a vendor's service, so Production Mode does not run the whole flow. It shows the first call going to the real address, and you choose what happens to it: + +| Run style | What happens | Needs internet | +|---|---|---| +| **Dry run** (default) | Each request is built and recorded in the inspector, with the real host it was addressed to, and nothing is sent. The inspector marks it **Dry run, not sent**. | No | +| **Live** | The request is sent to the vendor with no credential, and the vendor's own refusal comes back. | Yes | + +Live is off unless you choose it, and the banner says so. The demo removes the `Authorization` header from a live call. Plaid's body carries made-up `client_id` and `secret` values meant for the app emulator, which Plaid will reject. + +If a URL cannot be called, such as one that is empty or does not start with `http://` or `https://`, the banner says why and **Apply** stays disabled until it is fixed. A URL that is valid but unreachable fails the step with a message that names the vendor and the URL. Reset only ever wipes the app emulators, never a vendor's service. + ## Reset ```sh From 24204937869f26dbffe8e2c4ae80556e70f8afa4 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:40:20 -0800 Subject: [PATCH 06/13] refactor(wire): answer a dry run from the app emulator, drop live A dry run that stopped the flow read as a broken demo, and no vendor credential is ever connected. A vendor pointed at its own URL is still answered by the app emulator and the call records where it would have gone. Live sends are removed. --- .../acme-commerce-lenders/internal/api/api.go | 18 ++--- .../internal/api/api_test.go | 6 +- .../lender/reads_test.go | 32 ++++----- shared/wire/proxy.go | 29 ++------ shared/wire/proxy_test.go | 63 +++++++--------- shared/wire/recorder.go | 9 ++- shared/wire/switch.go | 71 ++++++++----------- shared/wire/switch_test.go | 44 +++++------- 8 files changed, 116 insertions(+), 156 deletions(-) diff --git a/apps/acme-commerce-lenders/internal/api/api.go b/apps/acme-commerce-lenders/internal/api/api.go index aedd8e0..b0ce6b3 100644 --- a/apps/acme-commerce-lenders/internal/api/api.go +++ b/apps/acme-commerce-lenders/internal/api/api.go @@ -148,22 +148,24 @@ func (h *handler) events(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"events": evs, "last": last, "generation": h.rec.Generation()}) } -// targets says where each vendor's calls go now, and whether the demo is in -// demo mode (every vendor at its app emulator) or production mode. +// targets says which base URL each vendor has been pointed at, and whether the +// demo is in demo mode (every vendor at its app emulator) or production mode. func (h *handler) targets(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, h.sw.State()) } -// setTargets changes the run style or any vendor's base URL. A URL that cannot -// be called is accepted and reported in the state, so the UI can say why a run -// will fail before it starts. +// setTargets points vendors at base URLs. A URL that cannot be used is accepted +// and reported in the state, so the UI can say why a run will fail before it +// starts. func (h *handler) setTargets(w http.ResponseWriter, r *http.Request) { - var u wire.Update + var u struct { + URLs map[string]string `json:"urls"` + } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&u); err != nil { - writeError(w, http.StatusBadRequest, errors.New("the body must be JSON like {\"urls\":{\"stripe\":\"https://api.stripe.com\"},\"style\":\"dry\"}")) + writeError(w, http.StatusBadRequest, errors.New("the body must be JSON like {\"urls\":{\"stripe\":\"https://api.stripe.com\"}}")) return } - if err := h.sw.Apply(u); err != nil { + if err := h.sw.Apply(u.URLs); err != nil { writeError(w, http.StatusBadRequest, err) return } diff --git a/apps/acme-commerce-lenders/internal/api/api_test.go b/apps/acme-commerce-lenders/internal/api/api_test.go index 82a3797..412c2f7 100644 --- a/apps/acme-commerce-lenders/internal/api/api_test.go +++ b/apps/acme-commerce-lenders/internal/api/api_test.go @@ -259,7 +259,7 @@ func TestWorldAdminReportsReadinessAndResetsTheRecorder(t *testing.T) { } } -func TestTargetsReportAndChangeWhereEachVendorIsCalled(t *testing.T) { +func TestTargetsReportAndChangeWhichBaseURLEachVendorIsPointedAt(t *testing.T) { sw := wire.NewSwitch( wire.Lane{Vendor: "stripe", Name: "Stripe", Emulator: wire.LocalStack("stripe"), ServiceURL: "https://api.stripe.com"}, ) @@ -279,7 +279,7 @@ func TestTargetsReportAndChangeWhereEachVendorIsCalled(t *testing.T) { return rec } - if st := get(); st.Mode != wire.ModeDemo || st.Style != wire.StyleDryRun || len(st.Lanes) != 1 { + if st := get(); st.Mode != wire.ModeDemo || len(st.Lanes) != 1 { t.Fatalf("start: %+v", st) } if rec := put(`{"urls":{"stripe":"https://api.stripe.com"}}`); rec.Code != 200 { @@ -291,7 +291,7 @@ func TestTargetsReportAndChangeWhereEachVendorIsCalled(t *testing.T) { if rec := put(`{"urls":{"stripe":"stripe"}}`); rec.Code != 200 || get().Lanes[0].Error == "" { t.Fatalf("an invalid URL is reported in the state: %d %+v", rec.Code, get()) } - for _, body := range []string{`{"style":"yolo"}`, `{"urls":{"nope":"http://x"}}`, `not json`} { + for _, body := range []string{`{"urls":{"nope":"http://x"}}`, `not json`} { if rec := put(body); rec.Code != 400 { t.Errorf("PUT %s: %d, want 400", body, rec.Code) } diff --git a/apps/acme-commerce-lenders/lender/reads_test.go b/apps/acme-commerce-lenders/lender/reads_test.go index 7908fbd..7816aea 100644 --- a/apps/acme-commerce-lenders/lender/reads_test.go +++ b/apps/acme-commerce-lenders/lender/reads_test.go @@ -391,7 +391,7 @@ func TestParseXeroDate(t *testing.T) { } } -func TestSwitchedVendorsFollowTheSwitchWithoutARestart(t *testing.T) { +func TestSwitchedVendorsStayOnTheEmulatorAndRecordWhereADryRunWouldGo(t *testing.T) { f := newFakeVendors(t) rec := wire.NewRecorder(10) sw := wire.NewSwitch( @@ -401,33 +401,33 @@ func TestSwitchedVendorsFollowTheSwitchWithoutARestart(t *testing.T) { ) v := NewSwitchedVendors(rec, sw) ctx := context.Background() + last := func() wire.Event { evs := rec.Events(); return evs[len(evs)-1] } - if _, err := v.connectXero(ctx, facts()); err != nil { - t.Fatalf("at the emulator: %v", err) + if _, err := v.connectXero(ctx, facts()); err != nil || last().WouldSend != "" { + t.Fatalf("at the emulator: %v, %+v", err, last()) } - // Pointed at Xero's own service in a dry run, nothing is sent. - if err := sw.Apply(wire.Update{URLs: map[string]string{"xero": "https://api.xero.com"}}); err != nil { + // Pointed at Xero's own service, the call is still answered by the app + // emulator, and the recording says where it would have gone. + if err := sw.Apply(map[string]string{"xero": "https://api.xero.com"}); err != nil { t.Fatal(err) } - _, err := v.connectXero(ctx, facts()) - if err == nil || !strings.Contains(err.Error(), "dry run") { - t.Fatalf("a dry run call: %v", err) + if _, err := v.connectXero(ctx, facts()); err != nil { + t.Fatalf("a dry run must still work: %v", err) } - last := rec.Events()[len(rec.Events())-1] - if last.Origin != wire.OriginBlocked || last.Upstream != "https://api.xero.com" { - t.Fatalf("recorded %+v", last) + if got := last(); got.WouldSend != "https://api.xero.com" || got.Origin != wire.OriginEmulator || got.Status != 200 { + t.Fatalf("recorded %+v", got) } // An invalid URL fails with a message that says what to do. - _ = sw.Apply(wire.Update{URLs: map[string]string{"xero": "xero"}}) + _ = sw.Apply(map[string]string{"xero": "xero"}) if _, err := v.connectXero(ctx, facts()); err == nil || !strings.Contains(err.Error(), "Use emulator") { t.Fatalf("an invalid URL: %v", err) } - // Back at the emulator, it works again. - _ = sw.Apply(wire.Update{URLs: map[string]string{"xero": f.srv.URL}}) - if _, err := v.connectXero(ctx, facts()); err != nil { - t.Fatalf("back at the emulator: %v", err) + // Back at the emulator, nothing is recorded as a dry run. + _ = sw.Apply(map[string]string{"xero": f.srv.URL}) + if _, err := v.connectXero(ctx, facts()); err != nil || last().WouldSend != "" { + t.Fatalf("back at the emulator: %v, %+v", err, last()) } } diff --git a/shared/wire/proxy.go b/shared/wire/proxy.go index 4ac1629..f709f1a 100644 --- a/shared/wire/proxy.go +++ b/shared/wire/proxy.go @@ -31,7 +31,6 @@ const ( const ( SourceLocalStack = "localstack" // LocalStack's gateway serves it SourceLocal = "local" // you run it yourself, such as a binary built from WonderTwin-AI/wondertwin - SourceService = "service" // the vendor's own service, reached by changing the base URL ) // Who produced a response. These values are provisional: they name the two @@ -39,7 +38,6 @@ const ( const ( OriginEmulator = "emulator" // an app emulator, wherever it runs OriginVendor = "vendor" // the vendor's own service or sandbox - OriginBlocked = "blocked" // a dry run built the request for the vendor's service and did not send it ) // Target is where one vendor's calls go. @@ -49,10 +47,11 @@ type Target struct { Upstream string // base URL dialled, such as http://127.0.0.1:4566 Host string // Host header sent upstream; empty keeps the proxy's own - // DryRun builds each request and records it, then refuses to send it. It - // is how the demo shows a vendor's real base URL without calling the - // vendor. A dry-run call is recorded with OriginBlocked. - DryRun bool + // WouldSend is the vendor's own base URL, when the demo has been pointed at + // it. The call is still answered by the app emulator, and nothing is sent to + // WouldSend. The recorded call carries it, so the inspector can show where + // the call would have gone. + WouldSend string // Invalid is why the target cannot be called, such as a base URL that does // not parse. Every call fails with it, and nothing is sent. Invalid string @@ -146,7 +145,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { Vendor: t.Vendor, Source: t.Source, Origin: t.origin(), - Upstream: t.BaseURL(), + WouldSend: t.WouldSend, EmulatorVersion: t.EmulatorVersion, ArtifactVersion: t.ArtifactVersion, Method: r.Method, @@ -164,14 +163,6 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.fail(w, ev, t, start, http.StatusBadGateway, errors.New(t.Invalid)) return } - if t.DryRun { - ev.Origin = OriginBlocked - p.fail(w, ev, t, start, http.StatusServiceUnavailable, fmt.Errorf( - "dry run: this %s %s was built for %s and was not sent. Switch the run to Live to send it, or use the emulator", - r.Method, r.URL.Path, t.BaseURL())) - return - } - out, err := http.NewRequestWithContext(r.Context(), r.Method, t.Upstream+r.URL.RequestURI(), bytes.NewReader(reqBody)) if err != nil { p.fail(w, ev, t, start, http.StatusBadGateway, err) @@ -179,19 +170,13 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } out.Header = r.Header.Clone() out.Header.Del(hopByHopConnection) - if t.Source == SourceService { - // The demo holds no credential for a vendor's service. Whatever it - // signs requests with is a made-up value for the app emulator. - out.Header.Del("Authorization") - } if t.Host != "" { out.Host = t.Host } resp, err := p.client.Do(out) if err != nil { - p.fail(w, ev, t, start, http.StatusBadGateway, fmt.Errorf( - "cannot reach %s at %s: %w. Check the URL in the settings, or switch it back to the emulator", t.Vendor, t.BaseURL(), err)) + p.fail(w, ev, t, start, http.StatusBadGateway, fmt.Errorf("cannot reach %s at %s: %w", t.Vendor, t.BaseURL(), err)) return } defer resp.Body.Close() diff --git a/shared/wire/proxy_test.go b/shared/wire/proxy_test.go index 37f8fa3..143c5e0 100644 --- a/shared/wire/proxy_test.go +++ b/shared/wire/proxy_test.go @@ -284,79 +284,70 @@ func TestTargetFromEnvReadsTheEmulatorVersion(t *testing.T) { } } -func TestProxyDryRunSendsNothingAndRecordsABlockedCall(t *testing.T) { - called := false - up, _ := upstream(t, func(http.ResponseWriter, *http.Request) { called = true }) +func TestProxyAnswersFromTheEmulatorAndRecordsWhereADryRunWouldHaveGone(t *testing.T) { + var got string + up, _ := upstream(t, func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"acct_1"}`)) + }) rec := NewRecorder(10) - px := httptest.NewServer(NewProxy(Target{Vendor: "stripe", Source: SourceService, Upstream: up.URL, Origin: OriginVendor, DryRun: true}, rec)) + px := httptest.NewServer(NewProxy(Target{Vendor: "stripe", Source: SourceLocal, Upstream: up.URL, WouldSend: "https://api.stripe.com"}, rec)) defer px.Close() - resp, err := http.Post(px.URL+"/v1/charges", "application/json", strings.NewReader(`{"amount":1}`)) + resp, err := http.Get(px.URL + "/v1/accounts/acct_1") if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if called { - t.Fatal("a dry run reached the upstream") - } - if resp.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "dry run") { - t.Fatalf("caller got %d %q", resp.StatusCode, body) + if resp.StatusCode != http.StatusOK || string(body) != `{"id":"acct_1"}` || got != "/v1/accounts/acct_1" { + t.Fatalf("the emulator did not answer: %d %q (upstream saw %q)", resp.StatusCode, body, got) } evs := rec.Events() - if len(evs) != 1 || evs[0].Origin != OriginBlocked || evs[0].Upstream != up.URL || evs[0].Path != "/v1/charges" || evs[0].Request.Content != `{"amount":1}` { + if len(evs) != 1 || evs[0].Origin != OriginEmulator || evs[0].WouldSend != "https://api.stripe.com" || evs[0].Status != http.StatusOK { t.Fatalf("events = %+v", evs) } } -func TestProxyInvalidTargetFailsWithItsReason(t *testing.T) { +func TestProxyRecordsNoWouldSendForACallAtTheEmulator(t *testing.T) { + up, _ := upstream(t, func(w http.ResponseWriter, r *http.Request) {}) rec := NewRecorder(10) - px := httptest.NewServer(NewProxy(Target{Vendor: "plaid", Source: SourceService, Invalid: "not a URL"}, rec)) + px := httptest.NewServer(NewProxy(Target{Vendor: "stripe", Source: SourceLocal, Upstream: up.URL}, rec)) defer px.Close() - - resp, err := http.Post(px.URL+"/link/token/create", "application/json", strings.NewReader(`{}`)) + resp, err := http.Get(px.URL + "/v1/balance") if err != nil { t.Fatal(err) } - body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "not a URL") { - t.Fatalf("caller got %d %q", resp.StatusCode, body) + if evs := rec.Events(); len(evs) != 1 || evs[0].WouldSend != "" { + t.Fatalf("events = %+v", evs) } } -func TestProxyLiveServiceCallCarriesNoAuthorization(t *testing.T) { - var auth string - up, _ := upstream(t, func(w http.ResponseWriter, r *http.Request) { - auth = r.Header.Get("Authorization") - w.WriteHeader(http.StatusUnauthorized) - }) +func TestProxyInvalidTargetFailsWithItsReason(t *testing.T) { rec := NewRecorder(10) - px := httptest.NewServer(NewProxy(Target{Vendor: "stripe", Source: SourceService, Upstream: up.URL, Origin: OriginVendor}, rec)) + px := httptest.NewServer(NewProxy(Target{Vendor: "plaid", Source: SourceLocal, Invalid: "not a URL"}, rec)) defer px.Close() - req, _ := http.NewRequest("GET", px.URL+"/v1/balance", nil) - req.Header.Set("Authorization", "Bearer sk_test_made_up") - resp, err := http.DefaultClient.Do(req) + resp, err := http.Post(px.URL+"/link/token/create", "application/json", strings.NewReader(`{}`)) if err != nil { t.Fatal(err) } + body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if auth != "" { - t.Fatalf("the service received Authorization %q", auth) - } - if evs := rec.Events(); len(evs) != 1 || evs[0].Origin != OriginVendor || evs[0].Status != http.StatusUnauthorized { - t.Fatalf("events = %+v", evs) + if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "not a URL") { + t.Fatalf("caller got %d %q", resp.StatusCode, body) } } -func TestProxyUnreachableURLSaysWhatToDo(t *testing.T) { +func TestProxyUnreachableEmulatorNamesTheVendorAndTheURL(t *testing.T) { up, _ := upstream(t, func(http.ResponseWriter, *http.Request) {}) dead := up.URL up.Close() rec := NewRecorder(10) - px := httptest.NewServer(NewProxy(Target{Vendor: "xero", Source: SourceService, Upstream: dead, Origin: OriginVendor}, rec)) + px := httptest.NewServer(NewProxy(Target{Vendor: "xero", Source: SourceLocal, Upstream: dead}, rec)) defer px.Close() resp, err := http.Get(px.URL + "/api.xro/2.0/Invoices") @@ -365,7 +356,7 @@ func TestProxyUnreachableURLSaysWhatToDo(t *testing.T) { } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "cannot reach xero at "+dead) || !strings.Contains(string(body), "switch it back to the emulator") { + if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "cannot reach xero at "+dead) { t.Fatalf("caller got %d %q", resp.StatusCode, body) } } diff --git a/shared/wire/recorder.go b/shared/wire/recorder.go index 34d8f60..bf23947 100644 --- a/shared/wire/recorder.go +++ b/shared/wire/recorder.go @@ -23,13 +23,16 @@ type Event struct { ID uint64 `json:"id"` Time time.Time `json:"time"` Vendor string `json:"vendor"` - Source string `json:"source"` // "localstack", "local" or "service": who answers the call + Source string `json:"source"` // "localstack" or "local": who hosts the app emulator // Origin says who produced the response, and EmulatorVersion and // ArtifactVersion say which build of it and which SDK or API version the // call was written against. The last two are empty when not known. Together // they make a recorded call comparable with the same call made elsewhere. - Origin string `json:"origin"` - Upstream string `json:"upstream,omitempty"` // the base URL the call was addressed to + Origin string `json:"origin"` + // WouldSend is set when the demo was pointed at the vendor's own service: + // the call was answered by the app emulator, and this is where it would have + // gone. Nothing was sent there. + WouldSend string `json:"would_send_to,omitempty"` EmulatorVersion string `json:"emulator_version,omitempty"` ArtifactVersion string `json:"artifact_version,omitempty"` Method string `json:"method"` diff --git a/shared/wire/switch.go b/shared/wire/switch.go index 88e86ff..69a8356 100644 --- a/shared/wire/switch.go +++ b/shared/wire/switch.go @@ -10,13 +10,7 @@ import ( // Modes a Switch reports. const ( ModeDemo = "demo" // every vendor is at its app emulator - ModeProduction = "production" // at least one vendor is at a URL that is not its app emulator -) - -// Run styles, for a vendor that is not at its app emulator. -const ( - StyleDryRun = "dry" // build and record the request, send nothing - StyleLive = "live" // send it, with no credential + ModeProduction = "production" // at least one vendor has been pointed at its own service ) // Lane is one vendor a Switch can point at its app emulator or at the @@ -36,40 +30,34 @@ type LaneState struct { EmulatorURL string `json:"emulator_url"` ServiceURL string `json:"service_url"` Service bool `json:"service"` // the URL is not the app emulator's - Error string `json:"error,omitempty"` // why the URL cannot be called + Error string `json:"error,omitempty"` // why the URL cannot be used } // State is what a Switch reports. type State struct { Mode string `json:"mode"` - Style string `json:"style"` Lanes []LaneState `json:"lanes"` } -// Update changes a Switch. A nil field is left alone. -type Update struct { - Style *string `json:"style,omitempty"` - URLs map[string]string `json:"urls,omitempty"` // vendor to base URL -} - -// Switch holds, for each vendor, the base URL the demo is calling now, and -// whether a call to a vendor's own service is sent. It is safe for concurrent -// use. The demo's flow asks it for a target on every call, so a change takes -// effect at once, with no restart. +// Switch holds, for each vendor, the base URL the demo has been pointed at. // -// A Switch never sends a credential: a call to a vendor's service is either -// dry-run, so it is built and not sent, or live with the Authorization header -// removed. +// The demo shows the mechanism of moving from an app emulator to a vendor: only +// the base URL changes. It holds no credential for a vendor's service, so a +// call is never sent to one. A call to a vendor that has been pointed at its +// own service is still answered by the app emulator, and is recorded with where +// it would have gone. +// +// A Switch is safe for concurrent use. The demo's flow asks it for a target on +// every call, so a change takes effect at once, with no restart. type Switch struct { mu sync.RWMutex lanes []Lane urls map[string]string - style string } -// NewSwitch starts every lane at its app emulator, in dry-run style. +// NewSwitch starts every lane at its app emulator. func NewSwitch(lanes ...Lane) *Switch { - s := &Switch{lanes: lanes, urls: map[string]string{}, style: StyleDryRun} + s := &Switch{lanes: lanes, urls: map[string]string{}} for _, l := range lanes { s.urls[l.Vendor] = l.Emulator.BaseURL() } @@ -87,7 +75,7 @@ func (s *Switch) lane(vendor string) (Lane, bool) { func clean(u string) string { return strings.TrimRight(strings.TrimSpace(u), "/") } -// Check says why a base URL cannot be called, or returns "". +// Check says why a base URL cannot be used, or returns "". func Check(name, raw string) string { raw = clean(raw) if raw == "" { @@ -95,12 +83,14 @@ func Check(name, raw string) string { } u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return fmt.Sprintf("%q is not a URL the demo can call. Start it with http:// or https://, such as https://example.com, or press Use emulator.", raw) + return fmt.Sprintf("%q is not a URL the demo can use. Start it with http:// or https://, such as https://example.com, or press Use emulator.", raw) } return "" } -// Target is where the vendor's calls go now. +// Target is where the vendor's calls go now: always the app emulator, with +// WouldSend set when the vendor has been pointed at another URL. A URL that +// cannot be used makes every call fail with the reason. func (s *Switch) Target(vendor string) Target { s.mu.RLock() defer s.mu.RUnlock() @@ -112,8 +102,11 @@ func (s *Switch) Target(vendor string) Target { if u == l.Emulator.BaseURL() { return l.Emulator } - t := Target{Vendor: vendor, Source: SourceService, Upstream: u, Origin: OriginVendor, DryRun: s.style == StyleDryRun} - t.Invalid = Check(l.Name, u) + if reason := Check(l.Name, u); reason != "" { + return Target{Vendor: vendor, Source: l.Emulator.Source, Invalid: reason} + } + t := l.Emulator + t.WouldSend = u return t } @@ -121,7 +114,7 @@ func (s *Switch) Target(vendor string) Target { func (s *Switch) State() State { s.mu.RLock() defer s.mu.RUnlock() - st := State{Mode: ModeDemo, Style: s.style} + st := State{Mode: ModeDemo} for _, l := range s.lanes { u := s.urls[l.Vendor] ls := LaneState{ @@ -138,24 +131,18 @@ func (s *Switch) State() State { return st } -// Apply makes the change. It rejects a style or a vendor it does not know, and -// otherwise accepts any URL: one that cannot be called is reported by State and +// Apply points vendors at URLs. It rejects a vendor it does not know, and +// otherwise accepts any URL: one that cannot be used is reported by State and // fails the call that uses it, so a presenter sees why. -func (s *Switch) Apply(u Update) error { +func (s *Switch) Apply(urls map[string]string) error { s.mu.Lock() defer s.mu.Unlock() - if u.Style != nil && *u.Style != StyleDryRun && *u.Style != StyleLive { - return fmt.Errorf("style must be %q or %q", StyleDryRun, StyleLive) - } - for v := range u.URLs { + for v := range urls { if _, ok := s.lane(v); !ok { return fmt.Errorf("no vendor named %q", v) } } - if u.Style != nil { - s.style = *u.Style - } - for v, raw := range u.URLs { + for v, raw := range urls { s.urls[v] = clean(raw) } return nil diff --git a/shared/wire/switch_test.go b/shared/wire/switch_test.go index 28287f8..c8933c0 100644 --- a/shared/wire/switch_test.go +++ b/shared/wire/switch_test.go @@ -14,11 +14,10 @@ func lanes() []Lane { func TestSwitchStartsInDemoMode(t *testing.T) { s := NewSwitch(lanes()...) - st := s.State() - if st.Mode != ModeDemo || st.Style != StyleDryRun { + if st := s.State(); st.Mode != ModeDemo { t.Fatalf("state = %+v", st) } - if got := s.Target("stripe"); got.Source != SourceLocalStack || got.Host == "" { + if got := s.Target("stripe"); got.Source != SourceLocalStack || got.Host == "" || got.WouldSend != "" { t.Fatalf("stripe target = %+v", got) } if got := s.Target("plaid"); got.Source != SourceLocal || got.Upstream != "http://127.0.0.1:5101" { @@ -26,35 +25,31 @@ func TestSwitchStartsInDemoMode(t *testing.T) { } } -func TestSwitchServiceURLIsProductionAndDryRunByDefault(t *testing.T) { +func TestSwitchPointedAtAServiceStaysOnTheEmulatorAndRemembersTheService(t *testing.T) { s := NewSwitch(lanes()...) - if err := s.Apply(Update{URLs: map[string]string{"stripe": "https://api.stripe.com/"}}); err != nil { + if err := s.Apply(map[string]string{"stripe": "https://api.stripe.com/"}); err != nil { t.Fatal(err) } if st := s.State(); st.Mode != ModeProduction || !st.Lanes[0].Service || st.Lanes[1].Service { t.Fatalf("state = %+v", st) } got := s.Target("stripe") - if !got.DryRun || got.Source != SourceService || got.Upstream != "https://api.stripe.com" || got.Invalid != "" { - t.Fatalf("target = %+v", got) - } - live := StyleLive - if err := s.Apply(Update{Style: &live}); err != nil { - t.Fatal(err) + if got.Source != SourceLocalStack || got.Host == "" || got.WouldSend != "https://api.stripe.com" || got.Invalid != "" { + t.Fatalf("target = %+v, want the emulator with WouldSend set", got) } - if got := s.Target("stripe"); got.DryRun { - t.Fatalf("live target is still a dry run: %+v", got) + if s.Target("plaid").WouldSend != "" { + t.Fatal("an untouched vendor has a WouldSend") } } func TestSwitchBackToEmulatorIsDemoMode(t *testing.T) { s := NewSwitch(lanes()...) - _ = s.Apply(Update{URLs: map[string]string{"stripe": "https://api.stripe.com"}}) - _ = s.Apply(Update{URLs: map[string]string{"stripe": s.State().Lanes[0].EmulatorURL}}) + _ = s.Apply(map[string]string{"stripe": "https://api.stripe.com"}) + _ = s.Apply(map[string]string{"stripe": s.State().Lanes[0].EmulatorURL}) if st := s.State(); st.Mode != ModeDemo { t.Fatalf("state = %+v", st) } - if got := s.Target("stripe"); got.Source != SourceLocalStack { + if got := s.Target("stripe"); got.WouldSend != "" { t.Fatalf("target = %+v", got) } } @@ -62,27 +57,24 @@ func TestSwitchBackToEmulatorIsDemoMode(t *testing.T) { func TestSwitchReportsAnInvalidURLAndFailsTheCall(t *testing.T) { for _, raw := range []string{"", " ", "stripe", "ftp://x", "http://"} { s := NewSwitch(lanes()...) - _ = s.Apply(Update{URLs: map[string]string{"stripe": raw}}) + _ = s.Apply(map[string]string{"stripe": raw}) lane := s.State().Lanes[0] - if lane.Error == "" || !strings.Contains(lane.Error, "emulator") && !strings.Contains(lane.Error, "Use") { + if lane.Error == "" || !strings.Contains(lane.Error, "Use") { t.Fatalf("%q: no helpful error: %+v", raw, lane) } if s.Target("stripe").Invalid == "" { - t.Fatalf("%q: target is callable", raw) + t.Fatalf("%q: target is usable", raw) } } } -func TestSwitchRejectsUnknownStyleAndVendor(t *testing.T) { +func TestSwitchRejectsAnUnknownVendorAndChangesNothing(t *testing.T) { s := NewSwitch(lanes()...) - bad := "yolo" - if err := s.Apply(Update{Style: &bad}); err == nil { - t.Fatal("accepted an unknown style") - } - if err := s.Apply(Update{URLs: map[string]string{"nope": "http://x"}}); err == nil { + err := s.Apply(map[string]string{"stripe": "https://api.stripe.com", "nope": "http://x"}) + if err == nil { t.Fatal("accepted an unknown vendor") } - if st := s.State(); st.Style != StyleDryRun { + if st := s.State(); st.Mode != ModeDemo { t.Fatalf("a rejected update changed the state: %+v", st) } } From db5d815a390fbdafab836d6910dd00ac026e5733 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:42:26 -0800 Subject: [PATCH 07/13] feat(brand): add a warning color for production mode The style guide has no warning color. Production Mode is not an error, so it needs a color that reads as caution without borrowing the error coral. Also corrects the stale 'SaaS Apps' wording in the brand README. --- web/apps/acme-commerce-lenders/src/styles/globals.css | 2 ++ web/packages/brand/README.md | 8 ++++---- web/packages/brand/shadcn-theme.css | 3 +++ web/packages/brand/src/variants/badge.ts | 2 ++ web/packages/brand/tokens.css | 7 +++++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/web/apps/acme-commerce-lenders/src/styles/globals.css b/web/apps/acme-commerce-lenders/src/styles/globals.css index c4c2811..3caf134 100644 --- a/web/apps/acme-commerce-lenders/src/styles/globals.css +++ b/web/apps/acme-commerce-lenders/src/styles/globals.css @@ -22,6 +22,8 @@ --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); diff --git a/web/packages/brand/README.md b/web/packages/brand/README.md index b993c8e..135b887 100644 --- a/web/packages/brand/README.md +++ b/web/packages/brand/README.md @@ -2,13 +2,13 @@ LocalStack brand tokens, logos and typeface for the demo UIs. Every demo UI imports this package, so the demos look like LocalStack and not like a one-off. -Positioning: these demos are **LocalStack for SaaS Apps**. +Positioning: these demos are **LocalStack for SaaS Applications**. ## What is here | Path | What it is | |---|---| -| `tokens.css` | The five official colors, the dark background gradient, an error color, and the Exo font face, as CSS custom properties (`--ls-*`) | +| `tokens.css` | The five official colors, the dark background gradient, an error color, a warning color, and the Exo font face, as CSS custom properties (`--ls-*`) | | `shadcn-theme.css` | The shadcn variable names (`--background`, `--primary` and the rest), set from the tokens, so components written against shadcn pick up the brand | | `src/variants/` | `cva` variants for `Button` and `Badge`. Mint is the action color. | | `logo/localstack-logo-horizontal-color.svg` | Gradient mark with a dark wordmark. Use on light backgrounds. | @@ -22,11 +22,11 @@ The LocalStack style guide and logo package, from the Branding page in Notion (A ## Rules, from the style guide -- Use only the colors in `tokens.css`. The style guide has no error color, so `--ls-danger` is an addition, used only for failures and declines. +- Use only the colors in `tokens.css`. The style guide has no error or warning color, so `--ls-danger` and `--ls-warning` are additions. `--ls-danger` is for failures and declines only. `--ls-warning` is for a state that is not wrong but is not the usual case, such as production mode. - Set text in Exo. - On a dark background use the light logo. On a light background use the color logo. Do not recolor or redraw the logo. - Keep the clear space shown on page 04 of the style guide (X/2 around the horizontal logo, X/5 around the vertical one). ## Not decided -The style guide has no lockup for a product line such as "LocalStack for SaaS Apps". Until the design owner decides one, set the product name as text in Exo beside or below the logo. Do not alter the logo to include it. +The style guide has no lockup for a product line such as "LocalStack for SaaS Applications". Until the design owner decides one, set the product name as text in Exo beside or below the logo. Do not alter the logo to include it. diff --git a/web/packages/brand/shadcn-theme.css b/web/packages/brand/shadcn-theme.css index 4687aed..3e7e4a1 100644 --- a/web/packages/brand/shadcn-theme.css +++ b/web/packages/brand/shadcn-theme.css @@ -36,6 +36,9 @@ --destructive: var(--ls-danger); --destructive-foreground: var(--ls-navy-start); + --warning: var(--ls-warning); + --warning-foreground: var(--ls-navy-start); + --border: color-mix(in oklab, white 14%, transparent); --input: color-mix(in oklab, white 18%, transparent); --ring: var(--ls-mint); diff --git a/web/packages/brand/src/variants/badge.ts b/web/packages/brand/src/variants/badge.ts index db24e89..c6a7034 100644 --- a/web/packages/brand/src/variants/badge.ts +++ b/web/packages/brand/src/variants/badge.ts @@ -12,6 +12,8 @@ export const badgeVariants = cva( success: 'bg-primary/15 text-primary', // Informational: which system served a call. info: 'bg-accent/25 text-foreground', + // Not wrong, but not the usual case: a dry run, production mode. + warning: 'bg-warning/15 text-warning', // Wrong: failed, declined. destructive: 'bg-destructive/15 text-destructive', }, diff --git a/web/packages/brand/tokens.css b/web/packages/brand/tokens.css index d13ae01..33f1cf8 100644 --- a/web/packages/brand/tokens.css +++ b/web/packages/brand/tokens.css @@ -36,6 +36,13 @@ */ --ls-danger: #ff7a8a; + /* + * Also not in the style guide, which has no warning color. Used only where + * something is not wrong but the reader should not mistake it for the usual + * case, such as the demo pointed at a vendor's own URL. + */ + --ls-warning: #ffc857; + /* Typeface: Exo, in Regular, Semibold, Bold and Extra Bold. */ --ls-font: "Exo", system-ui, -apple-system, "Segoe UI", sans-serif; } From 18a184568f2323b318973f4d274c29dc25bec464 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:42:26 -0800 Subject: [PATCH 08/13] feat(demo): make Production Mode a warning, and make the title the demo menu Production Mode now keeps the demo working: app emulators answer and the inspector tags each call with where it would have gone. The run-style buttons are gone with Live. The Demos button folds into the page title. --- .../acme-commerce-lenders/src/api/types.ts | 19 ++--- .../src/components/demo-switcher.test.tsx | 28 +++++++ .../src/components/demo-switcher.tsx | 35 +++++---- .../src/components/header.tsx | 8 +- .../src/components/mode-banner.test.tsx | 27 +++---- .../src/components/mode-banner.tsx | 75 +++++-------------- .../src/components/targets-dialog.tsx | 8 +- .../src/demo/demo-page.test.tsx | 18 +---- .../src/demo/demo-page.tsx | 9 +-- .../src/inspector/call-row.tsx | 23 +++--- .../src/inspector/inspector-page.test.tsx | 20 ++--- 11 files changed, 124 insertions(+), 146 deletions(-) create mode 100644 web/apps/acme-commerce-lenders/src/components/demo-switcher.test.tsx diff --git a/web/apps/acme-commerce-lenders/src/api/types.ts b/web/apps/acme-commerce-lenders/src/api/types.ts index b61080f..7762d74 100644 --- a/web/apps/acme-commerce-lenders/src/api/types.ts +++ b/web/apps/acme-commerce-lenders/src/api/types.ts @@ -105,11 +105,12 @@ export interface WireEvent { id: number time: string vendor: string - source: 'localstack' | 'local' | 'service' - /** Who produced the response. `blocked` is a dry run: built, not sent. */ - origin: 'emulator' | 'vendor' | 'blocked' - /** The base URL the call was addressed to. */ - upstream?: string + source: 'localstack' | 'local' + /** Who produced the response. */ + origin: 'emulator' | 'vendor' + /** Set when the demo was pointed at the vendor's own URL: where the call + * would have gone. The app emulator answered it, and nothing was sent. */ + would_send_to?: string method: string path: string status: number @@ -126,8 +127,6 @@ export interface EventsResponse { generation: number } -export type RunStyle = 'dry' | 'live' - /** One vendor, as the switch holds it. */ export interface Lane { vendor: string @@ -137,17 +136,15 @@ export interface Lane { service_url: string /** The URL is not the app emulator's. */ service: boolean - /** Why the URL cannot be called. */ + /** Why the URL cannot be used. */ error?: string } export interface Targets { mode: 'demo' | 'production' - style: RunStyle lanes: Lane[] } export interface TargetsUpdate { - style?: RunStyle - urls?: Record + urls: Record } diff --git a/web/apps/acme-commerce-lenders/src/components/demo-switcher.test.tsx b/web/apps/acme-commerce-lenders/src/components/demo-switcher.test.tsx new file mode 100644 index 0000000..e08fe00 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/demo-switcher.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { DemoSwitcher } from './demo-switcher' + +describe('DemoSwitcher', () => { + it('is the page title, and opens a menu with the current demo selected', async () => { + render( + , + ) + const title = screen.getByRole('heading', { level: 1 }) + expect(title).toHaveTextContent('Demo: ACME Commerce Lenders') + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + + await userEvent.click( + screen.getByRole('button', { name: 'Demo: ACME Commerce Lenders' }), + ) + expect(screen.getByRole('menu')).toBeInTheDocument() + expect( + screen.getByRole('menuitemradio', { + name: 'Demo: ACME Commerce Lenders', + }), + ).toBeChecked() + }) +}) diff --git a/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx b/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx index 898a01e..c8f3f21 100644 --- a/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx +++ b/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx @@ -1,27 +1,36 @@ import { Check, ChevronDown } from 'lucide-react' import { useState } from 'react' -import { Button } from '@/components/ui/button' /** The demos in this repo. There is one, so the menu has one entry. */ const demos = [{ id: 'acme-commerce-lenders', name: 'ACME Commerce Lenders' }] -export function DemoSwitcher({ current }: { current: string }) { +/** The page title, which is also the menu for choosing another demo. */ +export function DemoSwitcher({ + current, + title, +}: { + current: string + title: string +}) { const [open, setOpen] = useState(false) return (
- +

+ +

{open && (
{demos.map((d) => ( ))} diff --git a/web/apps/acme-commerce-lenders/src/components/header.tsx b/web/apps/acme-commerce-lenders/src/components/header.tsx index 2d7e831..1e76e70 100644 --- a/web/apps/acme-commerce-lenders/src/components/header.tsx +++ b/web/apps/acme-commerce-lenders/src/components/header.tsx @@ -17,9 +17,10 @@ export function Header({
-

- Demo: ACME Commerce Lenders -

+

@@ -28,7 +29,6 @@ export function Header({

- ({ const demo: Targets = { mode: 'demo', - style: 'dry', lanes: [ lane('stripe', 'Stripe', 'http://stripe.emulator'), lane('xero', 'Xero', 'http://xero.emulator'), @@ -33,7 +32,6 @@ const demo: Targets = { const production: Targets = { mode: 'production', - style: 'dry', lanes: [ lane('stripe', 'Stripe', 'https://api.stripe.com', true), lane('xero', 'Xero', 'http://xero.emulator'), @@ -63,23 +61,18 @@ describe('ModeBanner', () => { expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument() }) - it('says Production Mode, with the run style, when a vendor is at its service', async () => { + it('says Production Mode as a warning, and that nothing is sent', async () => { mocks.getTargets.mockResolvedValue(production) renderBanner() expect(await screen.findByText('Production Mode')).toBeInTheDocument() - expect(screen.getByRole('radio', { name: 'Dry run' })).toBeChecked() - expect(screen.getByRole('status')).toHaveTextContent(/nothing is sent/i) - }) - - it('switches to Live and says requests are sent without a credential', async () => { - mocks.getTargets.mockResolvedValue(production) - mocks.setTargets.mockResolvedValue({ ...production, style: 'live' }) - renderBanner() - await userEvent.click(await screen.findByRole('radio', { name: 'Live' })) - expect(mocks.setTargets).toHaveBeenCalledWith({ style: 'live' }) - await waitFor(() => - expect(screen.getByRole('status')).toHaveTextContent(/no credential/i), + expect(screen.getByRole('region', { name: 'Mode' })).toHaveTextContent( + /nothing is sent/i, + ) + expect(screen.getByRole('region', { name: 'Mode' })).toHaveAttribute( + 'data-mode', + 'production', ) + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument() }) it('cannot be dismissed', async () => { @@ -98,14 +91,14 @@ describe('ModeBanner', () => { { ...production.lanes[0], url: 'stripe', - error: '"stripe" is not a URL the demo can call.', + error: '"stripe" is not a URL the demo can use.', }, production.lanes[1], ], }) renderBanner() expect(await screen.findByRole('alert')).toHaveTextContent( - 'Stripe: "stripe" is not a URL the demo can call.', + 'Stripe: "stripe" is not a URL the demo can use.', ) }) }) diff --git a/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx b/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx index 489f8e8..51e75e3 100644 --- a/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx +++ b/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx @@ -1,31 +1,21 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Settings } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { Settings, TriangleAlert } from 'lucide-react' import { useState } from 'react' -import { api } from '@/api/client' import { targetQueries } from '@/api/queries' -import type { RunStyle, Targets } from '@/api/types' import { TargetsDialog } from '@/components/targets-dialog' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -const styleNote: Record = { - dry: 'Dry run: each request is built and shown in the inspector, and nothing is sent.', - live: 'Live: each request is sent to the vendor with no credential, so expect the vendor to refuse it.', -} - /** * Says which mode the demo is in, and cannot be dismissed. Demo mode calls the - * app emulators. Production mode addresses the vendors' own services, to show - * that only the base URL changed. + * app emulators at their own URLs. Production mode means a vendor has been + * pointed at its own service's URL, to show that only the base URL changes. The + * app emulators still answer, and the inspector shows where each call would + * have gone. */ export function ModeBanner() { const { data } = useQuery(targetQueries.current()) const [open, setOpen] = useState(false) - const queryClient = useQueryClient() - const setStyle = useMutation({ - mutationFn: (style: RunStyle) => api.setTargets({ style }), - onSuccess: (t: Targets) => queryClient.setQueryData(['targets'], t), - }) if (!data) return null const production = data.mode === 'production' @@ -38,7 +28,7 @@ export function ModeBanner() { className={cn( 'flex flex-col gap-2 rounded-xl border-2 px-4 py-3', production - ? 'border-destructive bg-destructive/15' + ? 'border-warning bg-warning/10' : 'border-primary/50 bg-primary/10', )} > @@ -46,57 +36,30 @@ export function ModeBanner() {
+ {production && } {production ? 'Production Mode' : 'Demo Mode'} {production - ? 'Calls are addressed to the vendors’ own services.' + ? 'Base URLs point at the vendors’ own services. Nothing is sent to them. The app emulators still answer, and the inspector shows where each call would have gone.' : 'Every call goes to an app emulator.'}
-
- {production && ( -
- {(['dry', 'live'] as const).map((s) => ( - - ))} -
- )} - -
+
- {production && ( -

- {styleNote[data.style]} -

- )} {problems.map((l) => (

{l.name}: {l.error} diff --git a/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx b/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx index 371e154..0cfbe2d 100644 --- a/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx +++ b/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx @@ -29,7 +29,7 @@ export function TargetsDialog({ }) return ( -

+

- The lender calls each vendor at the URL below. Change it and the - next call goes there. Nothing else in the app changes. + Point a vendor at its own service’s URL to see that only the base + URL changes. The demo holds no vendor credentials, so nothing is + sent there: the app emulator still answers, and the inspector + shows where each call would have gone.

-
- + } + > + + ) } diff --git a/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx b/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx index 279c633..3a4a963 100644 --- a/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx +++ b/web/apps/acme-commerce-lenders/src/demo/demo-page.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Application, Offer, Status } from '@/api/types' import { DemoPage } from './demo-page' +import { FlowProvider } from './flow-context' const mocks = vi.hoisted(() => ({ getStatus: vi.fn(), @@ -101,7 +102,9 @@ function renderPage() { }) return render( - + + + , ) } diff --git a/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx b/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx index 8dba77e..415b915 100644 --- a/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx +++ b/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx @@ -8,13 +8,14 @@ import { Readiness } from '@/components/readiness' import { StepList } from '@/components/step-list' import { Button } from '@/components/ui/button' import { Card, CardDescription, CardTitle } from '@/components/ui/card' -import { acceptSteps, applySteps, useDemoFlow } from './use-demo-flow' +import { useFlow } from './flow-context' +import { acceptSteps, applySteps } from './use-demo-flow' /** * The demo, in two clicks: apply, then accept. Reset puts it back at the start. */ export function DemoPage() { - const flow = useDemoFlow() + const flow = useFlow() const status = useQuery(statusQueries.current()) const targets = useQuery(targetQueries.current()) const badUrl = targets.data?.lanes.some((l) => l.error) === true diff --git a/web/apps/acme-commerce-lenders/src/demo/flow-context.test.tsx b/web/apps/acme-commerce-lenders/src/demo/flow-context.test.tsx new file mode 100644 index 0000000..6296022 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/demo/flow-context.test.tsx @@ -0,0 +1,31 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { FlowProvider, useFlow } from './flow-context' + +function Probe() { + return

busy: {String(useFlow().busy)}

+} + +describe('flow context', () => { + it('gives the flow to what is inside the provider', () => { + render( + + + + + , + ) + expect(screen.getByText('busy: false')).toBeInTheDocument() + }) + + it('fails loudly without a provider', () => { + const quiet = console.error + console.error = () => {} + try { + expect(() => render()).toThrow('useFlow needs a FlowProvider') + } finally { + console.error = quiet + } + }) +}) diff --git a/web/apps/acme-commerce-lenders/src/demo/flow-context.tsx b/web/apps/acme-commerce-lenders/src/demo/flow-context.tsx new file mode 100644 index 0000000..d14e138 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/demo/flow-context.tsx @@ -0,0 +1,19 @@ +import { createContext, type ReactNode, useContext } from 'react' +import { useDemoFlow } from './use-demo-flow' + +const FlowContext = createContext | null>(null) + +/** + * Holds the demo's progress above the router, so switching to the inspector and + * back does not lose where the presenter was. + */ +export function FlowProvider({ children }: { children: ReactNode }) { + const flow = useDemoFlow() + return {children} +} + +export function useFlow() { + const flow = useContext(FlowContext) + if (!flow) throw new Error('useFlow needs a FlowProvider above it') + return flow +} diff --git a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx index 18da6b2..2c2e1ea 100644 --- a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx +++ b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -5,7 +6,7 @@ import type { EventsResponse, WireEvent } from '@/api/types' import { readable } from './call-row' import { InspectorPage } from './inspector-page' -const mocks = vi.hoisted(() => ({ getEvents: vi.fn() })) +const mocks = vi.hoisted(() => ({ getEvents: vi.fn(), getTargets: vi.fn() })) vi.mock('@/api/client', async () => { const actual = @@ -42,6 +43,17 @@ const answer = ( last = events.at(-1)?.id ?? 0, ): EventsResponse => ({ events, last, generation }) +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + + , + ) +} + // Lets the next poll run, and the state it sets settle. const nextPoll = () => act(() => vi.advanceTimersByTimeAsync(800)) @@ -49,6 +61,8 @@ describe('InspectorPage', () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }) mocks.getEvents.mockReset() + mocks.getTargets.mockReset() + mocks.getTargets.mockResolvedValue({ mode: 'demo', lanes: [] }) }) afterEach(() => { vi.useRealTimers() @@ -56,7 +70,7 @@ describe('InspectorPage', () => { it('says so when the demo has made no calls', async () => { mocks.getEvents.mockResolvedValue(answer([])) - render() + renderPage() expect(await screen.findByText(/No calls yet/)).toBeTruthy() }) @@ -72,7 +86,7 @@ describe('InspectorPage', () => { }), ]), ) - render() + renderPage() const rows = await screen.findAllByTestId('call') expect(rows).toHaveLength(2) expect(within(rows[0]).getByText(/Recorded the advance/)).toBeTruthy() @@ -85,7 +99,7 @@ describe('InspectorPage', () => { mocks.getEvents.mockResolvedValue( answer([event(1, { would_send_to: 'https://api.stripe.com' })]), ) - render() + renderPage() const row = (await screen.findAllByTestId('call'))[0] expect( within(row).getByText(/Dry run: would go to https:\/\/api\.stripe\.com/), @@ -98,7 +112,7 @@ describe('InspectorPage', () => { mocks.getEvents .mockResolvedValueOnce(answer([event(1)])) .mockResolvedValue(answer([event(2)])) - render() + renderPage() await screen.findAllByTestId('call') await nextPoll() await waitFor(() => expect(screen.getAllByTestId('call')).toHaveLength(2)) @@ -111,7 +125,7 @@ describe('InspectorPage', () => { .mockResolvedValueOnce(answer([event(1), event(2)], 0)) .mockResolvedValueOnce(answer([], 1, 2)) .mockResolvedValue(answer([event(3)], 1)) - render() + renderPage() await waitFor(() => expect(screen.getAllByTestId('call')).toHaveLength(2)) await nextPoll() await screen.findByText(/No calls yet/) @@ -129,7 +143,7 @@ describe('InspectorPage', () => { event(2, { vendor: 'plaid', method: 'POST', path: '/auth/get' }), ]), ) - render() + renderPage() await screen.findAllByTestId('call') await user.click(screen.getByRole('button', { name: 'Plaid' })) const rows = screen.getAllByTestId('call') @@ -140,7 +154,7 @@ describe('InspectorPage', () => { it('opens a call to show its request and response', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) mocks.getEvents.mockResolvedValue(answer([event(1)])) - render() + renderPage() const row = await screen.findByTestId('call') expect(screen.queryByText(/"data"/)).toBeNull() await user.click(within(row).getByRole('button')) @@ -158,7 +172,7 @@ describe('InspectorPage', () => { mocks.getEvents.mockResolvedValue( answer([event(1, { status: 0, error: 'connection refused' })]), ) - render() + renderPage() expect(await screen.findByText('No answer')).toBeTruthy() }) @@ -166,7 +180,7 @@ describe('InspectorPage', () => { mocks.getEvents .mockRejectedValueOnce(new Error('HTTP 502')) .mockResolvedValue(answer([])) - render() + renderPage() expect(await screen.findByRole('alert')).toBeTruthy() await nextPoll() await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) diff --git a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx index 511b7ba..c733c10 100644 --- a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx +++ b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx @@ -1,6 +1,7 @@ -import logo from '@localstack-app-emulators/brand/logo/localstack-logo-horizontal-light.svg' import { useState } from 'react' -import { Button, buttonVariants } from '@/components/ui/button' +import { AppHeader } from '@/components/app-header' +import { ModeBanner } from '@/components/mode-banner' +import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { CallRow, vendorName } from './call-row' import { useEvents } from './use-events' @@ -19,23 +20,16 @@ export function InspectorPage() { return (
-
-
- LocalStack -
-
-

- Demo Inspector -

-

- ACME Commerce Lenders -

-
-
- - Back to the demo - -
+ + Demo Inspector + + } + /> + {error && ( diff --git a/web/apps/acme-commerce-lenders/src/routes/__root.tsx b/web/apps/acme-commerce-lenders/src/routes/__root.tsx index 4505b8d..6558fb3 100644 --- a/web/apps/acme-commerce-lenders/src/routes/__root.tsx +++ b/web/apps/acme-commerce-lenders/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { } from '@tanstack/react-router' import { AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' +import { FlowProvider } from '@/demo/flow-context' interface RouterContext { queryClient: QueryClient @@ -20,9 +21,11 @@ export const Route = createRootRouteWithContext()({ function RootLayout() { return ( -
- -
+ +
+ +
+
) } From 7c0e31603c97f5d8e3309b637cd24c6d8c802653 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:50:14 -0800 Subject: [PATCH 11/13] docs(demo): describe the header tabs and the new-tab arrow The Open Demo Inspector button became a tab, so the usage guide and the scenario describe the tabs and the arrow beside them. --- apps/acme-commerce-lenders/docs/scenarios.md | 2 +- apps/acme-commerce-lenders/docs/usage.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/acme-commerce-lenders/docs/scenarios.md b/apps/acme-commerce-lenders/docs/scenarios.md index a84a606..aa26f63 100644 --- a/apps/acme-commerce-lenders/docs/scenarios.md +++ b/apps/acme-commerce-lenders/docs/scenarios.md @@ -16,7 +16,7 @@ There is one scenario. It is the whole demo. 2. **Accept the offer and fund it.** One click. The lender registers the merchant's bank account in Stripe using the numbers Plaid holds, moves the advance to the merchant's Stripe account and pays it out to their bank. It then records the advance in the merchant's Xero books as money received against a loan. 3. **Reset.** One click puts everything back at the start, ready for the next audience. -**The second tab.** Open the inspector beside the demo (see [the inspector](usage.md#the-inspector)). Each click fills it with the calls behind it, in plain language: "Read the merchant's card sales from Stripe", "Fetched the bank account and routing numbers from Plaid", "Recorded the advance in the merchant's books". Open a row to show the request and the response. The badge on each row says whether LocalStack or a process on the laptop served it. +**The inspector.** Open it in a second browser tab beside the demo, from the arrow beside the header's tabs (see [the inspector](usage.md#the-inspector)). Each click fills it with the calls behind it, in plain language: "Read the merchant's card sales from Stripe", "Fetched the bank account and routing numbers from Plaid", "Recorded the advance in the merchant's books". Open a row to show the request and the response. The badge on each row says whether LocalStack or a process on the laptop served it. **What it proves.** An app that depends on three SaaS products can be built, run and shown end to end on a laptop, against app emulators that behave like the real vendors, with no vendor accounts and no sandbox quotas. Two clicks do real work across all three systems, and the seeded world is the same every run, so the demo never surprises the presenter. diff --git a/apps/acme-commerce-lenders/docs/usage.md b/apps/acme-commerce-lenders/docs/usage.md index 168f670..70da567 100644 --- a/apps/acme-commerce-lenders/docs/usage.md +++ b/apps/acme-commerce-lenders/docs/usage.md @@ -174,7 +174,7 @@ This needs a Xero app emulator that can create accounts and bank transactions, w ### The inspector -The demo's header has an **Open Demo Inspector** button, which opens in a second tab. Put it beside the demo to show what the lender asks Stripe, Plaid and Xero as each step runs. +The header has two tabs, **Demo** and **Demo Inspector**. Switching keeps the demo where it was. To show the calls as each step runs, press the arrow icon beside the tabs: it opens in a second browser tab, to put beside the demo. Each call is one row, newest first: From bc21638574f977a4d3bf007e536f26e0d44095b2 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 13:59:29 -0800 Subject: [PATCH 12/13] feat(demo): give the demo and the inspector the same title block With both views on one tab, the tabs say where you are, so the title, the demo menu and the scenario panel are the same on each. --- .../src/components/app-header.test.tsx | 16 ++++- .../src/components/app-header.tsx | 23 ++++--- .../src/components/header.tsx | 61 +------------------ .../src/components/scenario-info.tsx | 45 ++++++++++++++ .../src/inspector/inspector-page.tsx | 10 +-- 5 files changed, 76 insertions(+), 79 deletions(-) create mode 100644 web/apps/acme-commerce-lenders/src/components/scenario-info.tsx diff --git a/web/apps/acme-commerce-lenders/src/components/app-header.test.tsx b/web/apps/acme-commerce-lenders/src/components/app-header.test.tsx index b80530a..6de53f2 100644 --- a/web/apps/acme-commerce-lenders/src/components/app-header.test.tsx +++ b/web/apps/acme-commerce-lenders/src/components/app-header.test.tsx @@ -29,9 +29,7 @@ describe('AppHeader', () => { }) it('offers the inspector in a new tab from the demo only', () => { - const { rerender } = render( - Demo} subtitle="x" />, - ) + const { rerender } = render() const newTab = screen.getByRole('link', { name: 'Open Demo Inspector in a new tab', }) @@ -47,4 +45,16 @@ describe('AppHeader', () => { screen.queryByRole('link', { name: 'Open Demo Inspector in a new tab' }), ).not.toBeInTheDocument() }) + + it('has the same title block on both views, so the tabs do the wayfinding', () => { + const { rerender } = render() + const title = () => [ + screen.getByRole('heading', { level: 1 }).textContent, + screen.getByText('LocalStack for SaaS Applications').textContent, + ] + const onDemo = title() + rerender() + expect(title()).toEqual(onDemo) + expect(onDemo[0]).toBe('Demo: ACME Commerce Lenders') + }) }) diff --git a/web/apps/acme-commerce-lenders/src/components/app-header.tsx b/web/apps/acme-commerce-lenders/src/components/app-header.tsx index c959c41..902b25d 100644 --- a/web/apps/acme-commerce-lenders/src/components/app-header.tsx +++ b/web/apps/acme-commerce-lenders/src/components/app-header.tsx @@ -2,6 +2,8 @@ import logo from '@localstack-app-emulators/brand/logo/localstack-logo-horizonta import { useRouter } from '@tanstack/react-router' import { ExternalLink } from 'lucide-react' import type { MouseEvent, ReactNode } from 'react' +import { DemoSwitcher } from '@/components/demo-switcher' +import { ScenarioInfo } from '@/components/scenario-info' import { cn } from '@/lib/utils' type View = 'demo' | 'inspector' @@ -12,18 +14,15 @@ const views: { id: View; to: '/' | '/inspector'; label: string }[] = [ ] /** - * The header both views share: the logo and title on the left, the two views as - * tabs, and the view's own controls on the right. + * The header both views share. The title is the demo's, because the tabs, not + * the title, say which view you are on. The logo and title are on the left, the + * two views are tabs, and the view's own controls are on the right. */ export function AppHeader({ view, - title, - subtitle, children, }: { view: View - title: ReactNode - subtitle: string children?: ReactNode }) { // Without a router, such as in a test, a tab is an ordinary link. @@ -41,8 +40,16 @@ export function AppHeader({ LocalStack
- {title} -

{subtitle}

+
+ + +
+

+ LocalStack for SaaS Applications +

- } - > + - -
- ) -} diff --git a/web/apps/acme-commerce-lenders/src/components/scenario-info.tsx b/web/apps/acme-commerce-lenders/src/components/scenario-info.tsx new file mode 100644 index 0000000..b75c705 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/scenario-info.tsx @@ -0,0 +1,45 @@ +import { Info } from 'lucide-react' + +/** The scenario and story, in a panel that opens on hover or keyboard focus. */ +export function ScenarioInfo() { + return ( +
+ + +
+ ) +} diff --git a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx index c733c10..c4e7b1b 100644 --- a/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx +++ b/web/apps/acme-commerce-lenders/src/inspector/inspector-page.tsx @@ -20,15 +20,7 @@ export function InspectorPage() { return (
- - Demo Inspector - - } - /> + {error && ( From e37c7c2d953c146e7697a50b36a44b68565a5c26 Mon Sep 17 00:00:00 2001 From: tela Date: Thu, 5 Nov 2026 15:51:59 -0800 Subject: [PATCH 13/13] fix(demo): keep the mode label on one line The Production Mode pill shrank to fit the banner text and wrapped onto two lines. It now keeps its width and the text beside it wraps. --- web/apps/acme-commerce-lenders/src/components/mode-banner.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx b/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx index 51e75e3..276170c 100644 --- a/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx +++ b/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx @@ -33,10 +33,10 @@ export function ModeBanner() { )} >
-
+