feat: Added simple demo app.

This was just for the screenshot, but it can be a fun proof of concept.
This commit is contained in:
Hayden Hargreaves 2026-04-24 22:27:38 -07:00
parent b0394785c5
commit 15c73d7bfe
3 changed files with 233 additions and 1 deletions

197
internal/cli/demo.go Normal file
View File

@ -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
}

View File

@ -39,6 +39,10 @@ func Run(args []string) {
runCert() runCert()
return return
} }
if len(args) >= 2 && args[1] == "demo" {
runDemo()
return
}
cmd, ok := parseCommand(args) cmd, ok := parseCommand(args)
if !ok { if !ok {
@ -83,6 +87,7 @@ func parseCommand(args []string) (model.Command, bool) {
func displayHelp() { func displayHelp() {
helpText := ` helpText := `
usage: usage:
tap demo
tap cert tap cert
tap run -- <command> [args...] tap run -- <command> [args...]
` `

View File

@ -55,7 +55,7 @@ func TestDisplayHelpWritesToStderr(t *testing.T) {
displayHelp() 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) 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) { func TestRun_StartSessionFailureCallsFatalExit(t *testing.T) {
restore := installRunSeams(t) restore := installRunSeams(t)
defer restore() defer restore()