From 15c73d7bfeff4cc59720096bcc84b670790da6de Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 24 Apr 2026 22:27:38 -0700 Subject: [PATCH] feat: Added simple demo app. This was just for the screenshot, but it can be a fun proof of concept. --- internal/cli/demo.go | 197 +++++++++++++++++++++++++++++++++++++++ internal/cli/run.go | 5 + internal/cli/run_test.go | 32 ++++++- 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 internal/cli/demo.go diff --git a/internal/cli/demo.go b/internal/cli/demo.go new file mode 100644 index 0000000..6319b84 --- /dev/null +++ b/internal/cli/demo.go @@ -0,0 +1,197 @@ +package cli + +import ( + "net/http" + "net/url" + "time" + + "github.com/google/uuid" + "termtap.dev/internal/model" + "termtap.dev/internal/tui" +) + +func runDemo() { + events := demoEvents() + ch := make(chan model.Event, len(events)+8) + for _, ev := range events { + ch <- ev + } + + if err := runTUIFn(ch, tui.Controls{}); err != nil { + fatalExit(err) + } +} + +func demoEvents() []model.Event { + base := time.Now().Add(-12 * time.Second) + request := func(method, host, path string, status int, duration time.Duration, reqBody, respBody string, reqHeaders, respHeaders http.Header) []model.Event { + id := uuid.New() + startedAt := base + base = base.Add(850 * time.Millisecond) + + requestURL := (&url.URL{Scheme: "https", Host: host, Path: path}).String() + start := model.Event{ + Time: startedAt, + Type: model.EventTypeRequestStarted, + Request: model.Request{ + ID: id, + Method: method, + Host: host, + URL: path, + RawURL: requestURL, + QueryString: "", + Pending: true, + StartTime: startedAt, + RequestData: []byte(reqBody), + RequestHeaders: reqHeaders, + }, + } + + finish := model.Event{ + Time: startedAt.Add(duration), + Type: model.EventTypeRequestFinished, + Request: model.Request{ + ID: id, + Method: method, + Host: host, + URL: path, + RawURL: requestURL, + Status: status, + Duration: duration, + Pending: false, + StartTime: startedAt, + RequestData: []byte(reqBody), + ResponseData: []byte(respBody), + RequestHeaders: reqHeaders, + ResponseHeaders: respHeaders, + }, + } + + return []model.Event{start, finish} + } + + events := []model.Event{ + {Time: base, Type: model.EventTypeSessionStarted, Body: "demo session started"}, + {Time: base.Add(100 * time.Millisecond), Type: model.EventTypeProxyStarting, Body: "proxy warming up"}, + {Time: base.Add(200 * time.Millisecond), Type: model.EventTypeProxyStarted, Body: "proxy listening on 127.0.0.1:8080"}, + {Time: base.Add(300 * time.Millisecond), Type: model.EventTypeProcessStarting, Body: "starting demo app: go run ."}, + {Time: base.Add(450 * time.Millisecond), Type: model.EventTypeProcessStarted, PID: 48213, Body: "demo app pid 48213"}, + {Time: base.Add(500 * time.Millisecond), Type: model.EventTypeProcessStdout, Body: "tap • 12 requests captured"}, + {Time: base.Add(650 * time.Millisecond), Type: model.EventTypeProcessStdout, Body: "tap • replay mode enabled"}, + {Time: base.Add(800 * time.Millisecond), Type: model.EventTypeProcessStderr, Body: "tap • upstream 429s detected"}, + } + + events = append(events, request( + "POST", + "api.stripe.com", + "/v1/payment_intents", + 200, + 183*time.Millisecond, + `{"amount":4900,"currency":"usd","payment_method":"pm_card_visa"}`, + `{"id":"pi_3NtDemo","status":"succeeded"}`, + http.Header{"Authorization": []string{"Bearer sk_demo_123"}, "Content-Type": []string{"application/json"}}, + http.Header{"Content-Type": []string{"application/json"}}, + )...) + events = append(events, request( + "GET", + "api.github.com", + "/repos/lovable/tap/pulls", + 200, + 245*time.Millisecond, + "", + `[{"number":12,"title":"Add demo mode"}]`, + http.Header{"Accept": []string{"application/vnd.github+json"}}, + http.Header{"Content-Type": []string{"application/json"}}, + )...) + events = append(events, request( + "POST", + "api.openai.com", + "/v1/chat/completions", + 200, + 1832*time.Millisecond, + `{"model":"gpt-4.1-mini","stream":true}`, + `{"id":"chatcmpl_demo","choices":[{"delta":{"content":"hello"}}]}`, + http.Header{"Authorization": []string{"Bearer sk-openai-demo"}}, + http.Header{"Content-Type": []string{"application/json"}}, + )...) + events = append(events, request( + "GET", + "api.example.com", + "/v2/users/42/preferences", + 500, + 2105*time.Millisecond, + "", + `{"error":"internal_server_error"}`, + http.Header{}, + http.Header{"Content-Type": []string{"application/json"}}, + )...) + events = append(events, request( + "GET", + "hooks.slack.com", + "/services/T0B/xxx", + 200, + 312*time.Millisecond, + "", + `{"ok":true}`, + http.Header{}, + http.Header{"Content-Type": []string{"application/json"}}, + )...) + events = append(events, request( + "PUT", + "api.example.com", + "/v2/users/42/preferences", + 404, + 89*time.Millisecond, + `{"theme":"terminal"}`, + `{"error":"not_found"}`, + http.Header{"Content-Type": []string{"application/json"}}, + http.Header{"Content-Type": []string{"application/json"}}, + )...) + events = append(events, request( + "DELETE", + "api.stripe.com", + "/v1/subscriptions/sub_xyz", + 200, + 156*time.Millisecond, + "", + `{"status":"canceled"}`, + http.Header{}, + http.Header{"Content-Type": {"application/json"}}, + )...) + events = append(events, request( + "PATCH", + "api.openai.com", + "/v1/fine_tuning/jobs", + 429, + 423*time.Millisecond, + `{"suffix":"demo","hyperparameters":{"n_epochs":3}}`, + `{"error":"rate_limit_exceeded"}`, + http.Header{"Authorization": []string{"Bearer sk-openai-demo"}}, + http.Header{"Retry-After": []string{"2"}}, + )...) + events = append(events, request( + "POST", + "api.resend.com", + "/emails", + 422, + 67*time.Millisecond, + `{"to":"demo@example.com"}`, + `{"error":"unprocessable_entity"}`, + http.Header{"Content-Type": []string{"application/json"}}, + http.Header{"Content-Type": []string{"application/json"}}, + )...) + events = append(events, request( + "GET", + "cdn.jsdelivr.net", + "/npm/lodash@4.17.21/lodash.min.js", + 200, + 23*time.Millisecond, + "", + "/* minified js */", + http.Header{}, + http.Header{"Content-Type": []string{"application/javascript"}}, + )...) + events = append(events, model.Event{Time: base.Add(1200 * time.Millisecond), Type: model.EventTypeProcessStdout, Body: "tap • demo ready • press q to quit"}) + + return events +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 8b94294..809581d 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -39,6 +39,10 @@ func Run(args []string) { runCert() return } + if len(args) >= 2 && args[1] == "demo" { + runDemo() + return + } cmd, ok := parseCommand(args) if !ok { @@ -83,6 +87,7 @@ func parseCommand(args []string) (model.Command, bool) { func displayHelp() { helpText := ` usage: + tap demo tap cert tap run -- [args...] ` diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 7e49745..a4a04eb 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -55,7 +55,7 @@ func TestDisplayHelpWritesToStderr(t *testing.T) { displayHelp() }) - if !strings.Contains(stderr, "tap cert") || !strings.Contains(stderr, "tap run --") { + if !strings.Contains(stderr, "tap demo") || !strings.Contains(stderr, "tap cert") || !strings.Contains(stderr, "tap run --") { t.Fatalf("stderr missing usage text: %q", stderr) } } @@ -83,6 +83,36 @@ func TestRun_RoutesCertCommand(t *testing.T) { } } +func TestRun_RoutesDemoCommand(t *testing.T) { + restore := installRunSeams(t) + defer restore() + + called := installFatalSpy(t) + seen := 0 + runTUIFn = func(ch <-chan model.Event, _ tui.Controls) error { + for i := 0; i < 3; i++ { + select { + case ev := <-ch: + seen++ + if seen == 1 && ev.Type != model.EventTypeSessionStarted { + t.Fatalf("first demo event = %s, want %s", ev.Type, model.EventTypeSessionStarted) + } + default: + return nil + } + } + return nil + } + + Run([]string{"tap", "demo"}) + if *called { + t.Fatal("fatalExit should not be called for demo command") + } + if seen == 0 { + t.Fatal("expected demo event stream to be seeded") + } +} + func TestRun_StartSessionFailureCallsFatalExit(t *testing.T) { restore := installRunSeams(t) defer restore()