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 c10a4d0..70da567 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. `would_send_to` is set on a call made in Production Mode: the app emulator answered it, and it is the vendor's own base URL the call would have gone to. Nothing is sent there. - **`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 pointed at, and the mode (`demo` or `production`) | +| `PUT /api/targets` | Points vendors at base URLs: `{"urls":{"stripe":"https://api.stripe.com"}}`. A URL that cannot be used 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. @@ -172,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: @@ -185,6 +187,17 @@ 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 holds no credential for a vendor's service, and nobody should connect one, so **nothing is ever sent to a vendor's service**. In Production Mode the demo works exactly as before. The app emulator still answers every call, and each call the inspector shows is tagged **Dry run: would go to** the vendor's URL. + +If a URL cannot be used, 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. Reset only ever wipes the app emulators. + ## Reset ```sh diff --git a/apps/acme-commerce-lenders/internal/api/api.go b/apps/acme-commerce-lenders/internal/api/api.go index 833d6e7..b0ce6b3 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,30 @@ 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 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 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 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\"}}")) + return + } + if err := h.sw.Apply(u.URLs); 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..412c2f7 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 TestTargetsReportAndChangeWhichBaseURLEachVendorIsPointedAt(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 || 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{`{"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..7816aea 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 TestSwitchedVendorsStayOnTheEmulatorAndRecordWhereADryRunWouldGo(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() + last := func() wire.Event { evs := rec.Events(); return evs[len(evs)-1] } + + 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, 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) + } + if _, err := v.connectXero(ctx, facts()); err != nil { + t.Fatalf("a dry run must still work: %v", err) + } + 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(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, 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/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} } 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) + } +} diff --git a/shared/wire/proxy.go b/shared/wire/proxy.go index 0389618..f709f1a 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" @@ -46,11 +47,29 @@ 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 + // 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 + 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 +113,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 +139,50 @@ 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(), + WouldSend: t.WouldSend, + 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 + } + 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.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", 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 +204,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 +214,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..143c5e0 100644 --- a/shared/wire/proxy_test.go +++ b/shared/wire/proxy_test.go @@ -283,3 +283,80 @@ func TestTargetFromEnvReadsTheEmulatorVersion(t *testing.T) { t.Fatalf("an unset version is unknown, not guessed: %+v", got) } } + +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: SourceLocal, Upstream: up.URL, WouldSend: "https://api.stripe.com"}, rec)) + defer px.Close() + + 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 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 != OriginEmulator || evs[0].WouldSend != "https://api.stripe.com" || evs[0].Status != http.StatusOK { + t.Fatalf("events = %+v", evs) + } +} + +func TestProxyRecordsNoWouldSendForACallAtTheEmulator(t *testing.T) { + up, _ := upstream(t, func(w http.ResponseWriter, r *http.Request) {}) + rec := NewRecorder(10) + px := httptest.NewServer(NewProxy(Target{Vendor: "stripe", Source: SourceLocal, Upstream: up.URL}, rec)) + defer px.Close() + resp, err := http.Get(px.URL + "/v1/balance") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if evs := rec.Events(); len(evs) != 1 || evs[0].WouldSend != "" { + t.Fatalf("events = %+v", evs) + } +} + +func TestProxyInvalidTargetFailsWithItsReason(t *testing.T) { + rec := NewRecorder(10) + px := httptest.NewServer(NewProxy(Target{Vendor: "plaid", Source: SourceLocal, 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 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: SourceLocal, Upstream: dead}, 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) { + t.Fatalf("caller got %d %q", resp.StatusCode, body) + } +} diff --git a/shared/wire/recorder.go b/shared/wire/recorder.go index 897a105..bf23947 100644 --- a/shared/wire/recorder.go +++ b/shared/wire/recorder.go @@ -28,7 +28,11 @@ type Event struct { // 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"` + 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 new file mode 100644 index 0000000..69a8356 --- /dev/null +++ b/shared/wire/switch.go @@ -0,0 +1,149 @@ +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 has been pointed at its own service +) + +// 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 used +} + +// State is what a Switch reports. +type State struct { + Mode string `json:"mode"` + Lanes []LaneState `json:"lanes"` +} + +// Switch holds, for each vendor, the base URL the demo has been pointed at. +// +// 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 +} + +// NewSwitch starts every lane at its app emulator. +func NewSwitch(lanes ...Lane) *Switch { + s := &Switch{lanes: lanes, urls: map[string]string{}} + 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 used, 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 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: 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() + 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 + } + 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 +} + +// State reports every lane. +func (s *Switch) State() State { + s.mu.RLock() + defer s.mu.RUnlock() + st := State{Mode: ModeDemo} + 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 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(urls map[string]string) error { + s.mu.Lock() + defer s.mu.Unlock() + for v := range urls { + if _, ok := s.lane(v); !ok { + return fmt.Errorf("no vendor named %q", v) + } + } + 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 new file mode 100644 index 0000000..c8933c0 --- /dev/null +++ b/shared/wire/switch_test.go @@ -0,0 +1,80 @@ +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()...) + if st := s.State(); st.Mode != ModeDemo { + t.Fatalf("state = %+v", st) + } + 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" { + t.Fatalf("plaid target = %+v", got) + } +} + +func TestSwitchPointedAtAServiceStaysOnTheEmulatorAndRemembersTheService(t *testing.T) { + s := NewSwitch(lanes()...) + 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.Source != SourceLocalStack || got.Host == "" || got.WouldSend != "https://api.stripe.com" || got.Invalid != "" { + t.Fatalf("target = %+v, want the emulator with WouldSend set", got) + } + if s.Target("plaid").WouldSend != "" { + t.Fatal("an untouched vendor has a WouldSend") + } +} + +func TestSwitchBackToEmulatorIsDemoMode(t *testing.T) { + s := NewSwitch(lanes()...) + _ = 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.WouldSend != "" { + t.Fatalf("target = %+v", got) + } +} + +func TestSwitchReportsAnInvalidURLAndFailsTheCall(t *testing.T) { + for _, raw := range []string{"", " ", "stripe", "ftp://x", "http://"} { + s := NewSwitch(lanes()...) + _ = s.Apply(map[string]string{"stripe": raw}) + lane := s.State().Lanes[0] + 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 usable", raw) + } + } +} + +func TestSwitchRejectsAnUnknownVendorAndChangesNothing(t *testing.T) { + s := NewSwitch(lanes()...) + 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.Mode != ModeDemo { + t.Fatalf("a rejected update changed the state: %+v", st) + } +} 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..7762d74 100644 --- a/web/apps/acme-commerce-lenders/src/api/types.ts +++ b/web/apps/acme-commerce-lenders/src/api/types.ts @@ -106,6 +106,11 @@ export interface WireEvent { time: string vendor: 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 @@ -121,3 +126,25 @@ export interface EventsResponse { /** Changes when a reset clears the recording. */ generation: number } + +/** 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 used. */ + error?: string +} + +export interface Targets { + mode: 'demo' | 'production' + lanes: Lane[] +} + +export interface TargetsUpdate { + urls: Record +} 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 new file mode 100644 index 0000000..6de53f2 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/app-header.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { AppHeader } from './app-header' + +describe('AppHeader', () => { + it('shows both views as tabs, with the current one marked', () => { + render( + Demo Inspector} + subtitle="ACME Commerce Lenders" + />, + ) + const nav = screen.getByRole('navigation', { name: 'Views' }) + expect(nav).toHaveTextContent('Demo') + expect( + screen.getByRole('link', { name: 'Demo Inspector' }), + ).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('link', { name: 'Demo' })).not.toHaveAttribute( + 'aria-current', + ) + expect( + screen.getByRole('link', { name: 'Demo Inspector' }), + ).toHaveAttribute('href', '/inspector') + expect(screen.getByRole('link', { name: 'Demo' })).toHaveAttribute( + 'href', + '/', + ) + }) + + it('offers the inspector in a new tab from the demo only', () => { + const { rerender } = render() + const newTab = screen.getByRole('link', { + name: 'Open Demo Inspector in a new tab', + }) + expect(newTab).toHaveAttribute('target', '_blank') + rerender( + Demo Inspector} + subtitle="x" + />, + ) + expect( + 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 new file mode 100644 index 0000000..902b25d --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/app-header.tsx @@ -0,0 +1,90 @@ +import logo from '@localstack-app-emulators/brand/logo/localstack-logo-horizontal-light.svg' +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' + +const views: { id: View; to: '/' | '/inspector'; label: string }[] = [ + { id: 'demo', to: '/', label: 'Demo' }, + { id: 'inspector', to: '/inspector', label: 'Demo Inspector' }, +] + +/** + * 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, + children, +}: { + view: View + children?: ReactNode +}) { + // Without a router, such as in a test, a tab is an ordinary link. + const router = useRouter({ warn: false }) + const go = (to: string) => (e: MouseEvent) => { + if (!router || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) + return + e.preventDefault() + void router.navigate({ to }) + } + + return ( +
+
+ LocalStack +
+
+
+ + +
+

+ LocalStack for SaaS Applications +

+
+
+ +
+ {children} +
+
+ ) +} 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 new file mode 100644 index 0000000..c8f3f21 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/demo-switcher.tsx @@ -0,0 +1,52 @@ +import { Check, ChevronDown } from 'lucide-react' +import { useState } from 'react' + +/** The demos in this repo. There is one, so the menu has one entry. */ +const demos = [{ id: 'acme-commerce-lenders', name: 'ACME Commerce Lenders' }] + +/** 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 4be711f..95c5f55 100644 --- a/web/apps/acme-commerce-lenders/src/components/header.tsx +++ b/web/apps/acme-commerce-lenders/src/components/header.tsx @@ -1,6 +1,6 @@ -import logo from '@localstack-app-emulators/brand/logo/localstack-logo-horizontal-light.svg' -import { Eye, Info, RotateCcw } from 'lucide-react' -import { Button, buttonVariants } from '@/components/ui/button' +import { RotateCcw } from 'lucide-react' +import { AppHeader } from '@/components/app-header' +import { Button } from '@/components/ui/button' export function Header({ onReset, @@ -10,81 +10,11 @@ export function Header({ busy: boolean }) { return ( -
-
- LocalStack -
-
-
-

- Demo: ACME Commerce Lenders -

- -
-

- LocalStack for SaaS Applications -

-
-
-
- - - Open Demo Inspector - - -
-
- ) -} - -/** The scenario and story, in a panel that opens on hover or keyboard focus. */ -function ScenarioInfo() { - return ( -
- - -
+ + + ) } diff --git a/web/apps/acme-commerce-lenders/src/components/mode-banner.test.tsx b/web/apps/acme-commerce-lenders/src/components/mode-banner.test.tsx new file mode 100644 index 0000000..93e0011 --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/mode-banner.test.tsx @@ -0,0 +1,163 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Targets } from '@/api/types' +import { ModeBanner } from './mode-banner' + +const mocks = vi.hoisted(() => ({ 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', + lanes: [ + lane('stripe', 'Stripe', 'http://stripe.emulator'), + lane('xero', 'Xero', 'http://xero.emulator'), + ], +} + +const production: Targets = { + mode: 'production', + 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 as a warning, and that nothing is sent', async () => { + mocks.getTargets.mockResolvedValue(production) + renderBanner() + expect(await screen.findByText('Production Mode')).toBeInTheDocument() + 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 () => { + 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 use.', + }, + production.lanes[1], + ], + }) + renderBanner() + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Stripe: "stripe" is not a URL the demo can use.', + ) + }) +}) + +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..276170c --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/mode-banner.tsx @@ -0,0 +1,71 @@ +import { useQuery } from '@tanstack/react-query' +import { Settings, TriangleAlert } from 'lucide-react' +import { useState } from 'react' +import { targetQueries } from '@/api/queries' +import { TargetsDialog } from '@/components/targets-dialog' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +/** + * Says which mode the demo is in, and cannot be dismissed. Demo mode calls the + * 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) + + if (!data) return null + const production = data.mode === 'production' + const problems = data.lanes.filter((l) => l.error) + + return ( +
+
+
+ + {production && } + {production ? 'Production Mode' : 'Demo Mode'} + + + {production + ? '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.'} + +
+ +
+ {problems.map((l) => ( +

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

+ ))} + {open && setOpen(false)} />} +
+ ) +} 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/components/targets-dialog.tsx b/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx new file mode 100644 index 0000000..0cfbe2d --- /dev/null +++ b/web/apps/acme-commerce-lenders/src/components/targets-dialog.tsx @@ -0,0 +1,108 @@ +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 +

+

+ 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. +

+
+ +
+ + {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..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,9 +4,11 @@ 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(), + getTargets: vi.fn(), reset: vi.fn(), apply: vi.fn(), connect: vi.fn(), @@ -100,7 +102,9 @@ function renderPage() { }) return render( - + + + , ) } @@ -366,3 +370,45 @@ 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('shows the app emulators, which still answer in production mode', async () => { + mocks.getTargets.mockResolvedValue({ mode: 'production', lanes: lanes() }) + renderPage() + expect(await screen.findByTestId('emulator-stripe')).toBeInTheDocument() + const apply = await screen.findByRole('button', { + name: 'Apply for working capital', + }) + await waitFor(() => expect(apply).toBeEnabled()) + }) + + it('holds Apply back, and says to fix the URL, when a base URL cannot be called', async () => { + mocks.getTargets.mockResolvedValue({ + mode: 'production', + lanes: lanes('"stripe" is not a URL the demo can use.'), + }) + 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..415b915 100644 --- a/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx +++ b/web/apps/acme-commerce-lenders/src/demo/demo-page.tsx @@ -1,21 +1,27 @@ 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' 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 ready = status.data?.ready === true + const targets = useQuery(targetQueries.current()) + const badUrl = targets.data?.lanes.some((l) => l.error) === true + // The app emulators answer in both modes, so the run needs them either way. A + // URL that cannot be used holds it back too. + const ready = status.data?.ready === true && !badUrl const merchant = status.data?.facts?.merchant ?? 'The merchant' const app = flow.application @@ -28,6 +34,7 @@ export function DemoPage() { return (
+ {flow.resetError && ( @@ -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/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/call-row.tsx b/web/apps/acme-commerce-lenders/src/inspector/call-row.tsx index c7e43aa..eb1e649 100644 --- a/web/apps/acme-commerce-lenders/src/inspector/call-row.tsx +++ b/web/apps/acme-commerce-lenders/src/inspector/call-row.tsx @@ -1,4 +1,4 @@ -import { ChevronRight } from 'lucide-react' +import { ChevronRight, TriangleAlert } from 'lucide-react' import { useState } from 'react' import type { WireBody, WireEvent } from '@/api/types' import { Badge } from '@/components/ui/badge' @@ -11,6 +11,11 @@ const vendorNames: Record = { xero: 'Xero', } +const sourceName: Record = { + localstack: 'LocalStack', + local: 'Local', +} + export const vendorName = (vendor: string) => vendorNames[vendor] ?? vendor function ms(ns: number) { @@ -66,11 +71,17 @@ export function CallRow({ event }: { event: WireEvent }) {
{event.method} {event.path}
+ {event.would_send_to && ( +
+ + + Dry run: would go to {event.would_send_to}. Nothing was sent. + +
+ )} {vendorName(event.vendor)} - - {event.source === 'localstack' ? 'LocalStack' : 'Local'} - + {sourceName[event.source]} {event.error ? 'No answer' : event.status} 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..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 = @@ -25,6 +26,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, @@ -41,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)) @@ -48,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() @@ -55,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() }) @@ -71,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() @@ -80,11 +95,24 @@ describe('InspectorPage', () => { expect(within(rows[1]).getByText('LocalStack')).toBeTruthy() }) + it('tags a call that a dry run would have sent elsewhere, with where it would go', async () => { + mocks.getEvents.mockResolvedValue( + answer([event(1, { would_send_to: 'https://api.stripe.com' })]), + ) + renderPage() + const row = (await screen.findAllByTestId('call'))[0] + expect( + within(row).getByText(/Dry run: would go to https:\/\/api\.stripe\.com/), + ).toBeTruthy() + // The app emulator answered it, so it is a normal 200 and not a failure. + expect(within(row).getByText('200')).toBeTruthy() + }) + it('asks only for what is new, and adds it to the list', async () => { 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)) @@ -97,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/) @@ -115,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') @@ -126,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')) @@ -144,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() }) @@ -152,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..c4e7b1b 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,8 @@ export function InspectorPage() { return (
-
-
- LocalStack -
-
-

- Demo Inspector -

-

- ACME Commerce Lenders -

-
-
- - Back to the demo - -
+ + {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 ( -
- -
+ +
+ +
+
) } 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; }