From d55c2ccd1175c5907bb32b0b118e7ce3a2c37361 Mon Sep 17 00:00:00 2001 From: tela Date: Mon, 9 Nov 2026 21:08:10 -0800 Subject: [PATCH] feat(demo): add a doctor command that says how to fix each problem A presenter hitting a blank page, a busy port or a restarted emulator had to read three docs to find which. One command now checks all of it and names the fix. --- .../docs/troubleshooting.md | 2 +- .../acme-commerce-lenders/internal/cli/cli.go | 5 +- .../internal/cli/cli_test.go | 55 ++++++++++ .../internal/cli/doctor.go | 102 ++++++++++++++++++ demos/acme-commerce-lenders/internal/ui/ui.go | 7 ++ 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 demos/acme-commerce-lenders/internal/cli/doctor.go diff --git a/demos/acme-commerce-lenders/docs/troubleshooting.md b/demos/acme-commerce-lenders/docs/troubleshooting.md index 020fa28..dba5796 100644 --- a/demos/acme-commerce-lenders/docs/troubleshooting.md +++ b/demos/acme-commerce-lenders/docs/troubleshooting.md @@ -2,7 +2,7 @@ Status: Ready to rehearse -Check first: `make kit APP=acme-commerce-lenders ARGS=ready`. It lists each app emulator, who serves it and whether it is healthy, and says what to do about one that is not. Each row below is a failure seen while building or rehearsing the demo. +Check first: `make kit APP=acme-commerce-lenders ARGS=doctor`. It checks the web UI build, the state file, the port, each app emulator and the seeded world, and says what to do about each failure. `ARGS=ready` checks only the app emulators. Each row below is a failure seen while building or rehearsing the demo. | Symptom | Cause | Fix | |---|---|---| diff --git a/demos/acme-commerce-lenders/internal/cli/cli.go b/demos/acme-commerce-lenders/internal/cli/cli.go index 187f7c5..f80d1f4 100644 --- a/demos/acme-commerce-lenders/internal/cli/cli.go +++ b/demos/acme-commerce-lenders/internal/cli/cli.go @@ -30,6 +30,7 @@ Commands: ready check that the Stripe, Plaid and Xero app emulators are up seed seed the world into the app emulators reset wipe the app emulators and seed the world again + doctor check this machine and the app emulators, and say how to fix each problem Each vendor is served by LocalStack unless _EMULATOR_URL says you run its app emulator yourself, for example PLAID_EMULATOR_URL=http://127.0.0.1:5101. @@ -49,7 +50,7 @@ func Run(ctx context.Context, args []string, getenv func(string) string, stdout, addr := fs.String("addr", "127.0.0.1:8080", "address the UI listens on (serve only)") switch cmd { - case "serve", "ready", "seed", "reset": + case "serve", "ready", "seed", "reset", "doctor": case "help": fmt.Fprint(stdout, usage) return 0 @@ -71,6 +72,8 @@ func Run(ctx context.Context, args []string, getenv func(string) string, stdout, err = app.seed() case "reset": err = app.reset() + case "doctor": + err = app.doctor(*addr) } if err != nil { fmt.Fprintln(stderr, "error:", err) diff --git a/demos/acme-commerce-lenders/internal/cli/cli_test.go b/demos/acme-commerce-lenders/internal/cli/cli_test.go index eff89cf..9839b14 100644 --- a/demos/acme-commerce-lenders/internal/cli/cli_test.go +++ b/demos/acme-commerce-lenders/internal/cli/cli_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "net" "net/http" "net/http/httptest" "path/filepath" @@ -12,6 +13,7 @@ import ( "testing" "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/internal/fakevendors" + "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/internal/ui" "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/world" ) @@ -172,3 +174,56 @@ func TestTheServerServesTheAPIAndTheUI(t *testing.T) { t.Fatalf("the connect calls should be recorded as events: %d %s", code, body) } } + +func TestDoctorPassesWhenEverythingIsUpAndSeeded(t *testing.T) { + var resets atomic.Int32 + env := envFor(fakevendors.New(t, &resets).URL) + state := filepath.Join(t.TempDir(), "state.json") + getenv := func(k string) string { return env[k] } + var o, e bytes.Buffer + if code := Run(context.Background(), []string{"seed", "--state", state}, getenv, &o, &e); code != 0 { + t.Fatalf("seed: %s", e.String()) + } + o.Reset() + code := Run(context.Background(), []string{"doctor", "--state", state, "--addr", "127.0.0.1:0"}, getenv, &o, &e) + if strings.Contains(o.String(), "stripe") == false || !strings.Contains(o.String(), "the world is seeded and every app emulator still holds it") { + t.Fatalf("code=%d\n%s", code, o.String()) + } + // Only the UI check can fail, and only when this checkout has not run make web. + wantFailed := 1 + if ui.Built() { + wantFailed = 0 + } + if strings.Count(o.String(), "FAILED") != wantFailed { + t.Fatalf("want %d failed check(s):\n%s", wantFailed, o.String()) + } +} + +func TestDoctorNamesEachProblemAndItsFix(t *testing.T) { + var resets atomic.Int32 + up := fakevendors.New(t, &resets) + down := httptest.NewServer(http.NotFoundHandler()) + downURL := down.URL + down.Close() + env := envFor(up.URL) + env["XERO_EMULATOR_URL"] = downURL + + busy, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer busy.Close() + + code, out, errOut := run(t, env, "doctor", "--addr", busy.Addr().String()) + if code != 1 { + t.Fatalf("code=%d", code) + } + for _, want := range []string{"serve --addr", "XERO_EMULATOR_URL", "the world is seeded", "run: reset", "XERO_EMULATOR_VERSION"} { + if !strings.Contains(out, want) { + t.Errorf("doctor should say %q:\n%s", want, out) + } + } + if !strings.Contains(errOut, "problem(s) found") { + t.Errorf("err=%q", errOut) + } +} diff --git a/demos/acme-commerce-lenders/internal/cli/doctor.go b/demos/acme-commerce-lenders/internal/cli/doctor.go new file mode 100644 index 0000000..e6d1f84 --- /dev/null +++ b/demos/acme-commerce-lenders/internal/cli/doctor.go @@ -0,0 +1,102 @@ +package cli + +import ( + "fmt" + "net" + "os" + "path/filepath" + + "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/internal/api" + "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/internal/ui" + "github.com/localstack-samples/localstack-app-emulators/shared/vendorcfg" +) + +// doctor runs every check, prints one line each with the fix for a failure, and +// fails if any check failed. It changes nothing. +func (a *app) doctor(addr string) error { + bad := 0 + report := func(ok bool, what, fix string) { + if ok { + fmt.Fprintf(a.out, " ok %s\n", what) + return + } + bad++ + fmt.Fprintf(a.out, " FAILED %s\n %s\n", what, fix) + } + + fmt.Fprintln(a.out, "This machine:") + report(ui.Built(), "the web UI is built into this binary", + "this binary has only the placeholder page; run: make web APP=acme-commerce-lenders, then rebuild") + report(a.stateWritable(), "the state file can be written at "+a.statePath, + "make that folder writable, or choose another with --state") + if err := portFree(addr); err != nil { + report(false, "the demo can listen on "+addr, + "something is already on that port, often an earlier run; stop it, or use serve --addr 127.0.0.1:8081") + } else { + report(true, "the demo can listen on "+addr, "") + } + + fmt.Fprintln(a.out, "App emulators:") + store, err := a.store() + if err != nil { + return err + } + st, err := api.WorldAdmin{Targets: a.targets, HC: a.httpClient(), Store: store}.Status(a.ctx) + if err != nil { + report(false, "the state file can be read", err.Error()+"; delete it and run: reset") + return fmt.Errorf("%d problem(s) found", bad) + } + for _, e := range st.Emulators { + report(e.OK(), fmt.Sprintf("%s (%s) %s at %s", e.Vendor, e.Source, e.State, e.URL), e.Hint) + } + for _, t := range a.targets.All() { + if t.Source == "localstack" { + continue + } + if a.getenv(vendorcfg.EmulatorVersionEnvName(t.Vendor)) == "" { + fmt.Fprintf(a.out, " note %s has no version recorded; set %s to show it in the inspector\n", t.Vendor, vendorcfg.EmulatorVersionEnvName(t.Vendor)) + } + } + + fmt.Fprintln(a.out, "The world:") + switch { + case !st.Seeded: + report(false, "the world is seeded", "run: reset (or press Reset in the UI)") + case len(st.Stale) > 0: + for _, s := range st.Stale { + report(false, fmt.Sprintf("%s still holds the seeded world", s.Vendor), s.Reason+"; run: reset") + } + default: + report(true, "the world is seeded and every app emulator still holds it", "") + } + + if bad > 0 { + return fmt.Errorf("%d problem(s) found", bad) + } + fmt.Fprintln(a.out, "all checks passed") + return nil +} + +// stateWritable reports whether the state file's folder exists or can be made, +// and can be written to. It leaves nothing behind. +func (a *app) stateWritable() bool { + dir := filepath.Dir(a.statePath) + if err := os.MkdirAll(dir, 0o750); err != nil { + return false + } + f, err := os.CreateTemp(dir, ".doctor-*") + if err != nil { + return false + } + name := f.Name() + _ = f.Close() + return os.Remove(name) == nil +} + +func portFree(addr string) error { + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + return l.Close() +} diff --git a/demos/acme-commerce-lenders/internal/ui/ui.go b/demos/acme-commerce-lenders/internal/ui/ui.go index 827fe21..4499dab 100644 --- a/demos/acme-commerce-lenders/internal/ui/ui.go +++ b/demos/acme-commerce-lenders/internal/ui/ui.go @@ -23,6 +23,13 @@ func Handler() http.Handler { return handler(sub) } +// Built reports whether the binary carries the built web UI rather than the +// placeholder page. +func Built() bool { + sub, err := fs.Sub(assets, "assets") + return err == nil && exists(sub, "index.html") +} + // handler serves fsys as a single-page app: // - a file that exists is served as it is; // - a path with no file extension that does not exist, such as /inspector, is