From ba57f0519c15f29efb2896e910bb57bcbc762994 Mon Sep 17 00:00:00 2001 From: Albert Wu Date: Wed, 15 Jul 2026 23:50:37 -0700 Subject: [PATCH] feat(sqsim): add interactive scenario view --- MODULE.bazel | 1 + go.mod | 17 ++ go.sum | 39 +++ sqsim/runner/runner.go | 74 ++++-- sqsim/runner/runner_test.go | 69 +++++ sqsim/tui/BUILD.bazel | 27 ++ sqsim/tui/stage.go | 104 ++++++++ sqsim/tui/tui.go | 484 ++++++++++++++++++++++++++++++++++++ sqsim/tui/tui_test.go | 171 +++++++++++++ tool/sqsim/BUILD.bazel | 2 + tool/sqsim/main.go | 46 ++-- tool/sqsim/main_test.go | 23 +- 12 files changed, 1022 insertions(+), 35 deletions(-) create mode 100644 sqsim/tui/BUILD.bazel create mode 100644 sqsim/tui/stage.go create mode 100644 sqsim/tui/tui.go create mode 100644 sqsim/tui/tui_test.go diff --git a/MODULE.bazel b/MODULE.bazel index 61bdeb2a..9e466416 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -49,6 +49,7 @@ go_deps.module( # All *direct* Go dependencies of the module have to be listed explicitly use_repo( go_deps, + "com_github_charmbracelet_bubbletea", "com_github_data_dog_go_sqlmock", "com_github_go_sql_driver_mysql", "com_github_spf13_cobra", diff --git a/go.mod b/go.mod index ba21f2e4..70dd1e22 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.5 require ( github.com/DATA-DOG/go-sqlmock v1.5.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/go-sql-driver/mysql v1.6.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -20,23 +21,39 @@ require ( require ( github.com/BurntSushi/toml v1.2.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/golang/mock v1.7.0-rc.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.11.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/twmb/murmur3 v1.1.8 // indirect github.com/uber/tchannel-go v1.34.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/dig v1.17.1 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 199e1c87..49261b1f 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -20,10 +22,24 @@ github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -91,6 +107,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -98,6 +122,12 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= @@ -132,6 +162,9 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -167,6 +200,8 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/uber/tchannel-go v1.34.4 h1:Wi7SSfUQbJ4lPqS4trP1uAZvWu46oK0qz0tOodcfcbY= github.com/uber/tchannel-go v1.34.4/go.mod h1:ERHDsQa50nNJxV8Mm6V4nxPWyGvGxiV+T/dUNRzmoC4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -197,6 +232,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE= golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -245,7 +282,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/sqsim/runner/runner.go b/sqsim/runner/runner.go index 5c1c755c..805626e6 100644 --- a/sqsim/runner/runner.go +++ b/sqsim/runner/runner.go @@ -25,6 +25,11 @@ import ( "github.com/uber/submitqueue/sqsim/model" ) +const ( + maxSubmissionsPerCycle = 25 + maxSubmissionsBetweenPolls = 100 +) + // Gateway is the public SubmitQueue surface used by SQSim. type Gateway interface { // Land submits one synthetic change. @@ -141,6 +146,7 @@ type requestState struct { submitted bool terminalObserved bool historyLoaded bool + historyDirty bool } // Run executes and verifies one scenario. @@ -181,14 +187,14 @@ func Run(ctx context.Context, options Options) (Report, error) { } emit(options, startedAt, states, false) + submitted := 0 + submittedSincePoll := 0 for { now := options.Clock.Now() - changed := false - submitted := 0 + submittedThisCycle := 0 for i := range states { state := &states[i] if state.submitted { - submitted++ continue } if now.Sub(startedAt) < state.submitAfter { @@ -205,20 +211,34 @@ func Run(ctx context.Context, options Options) (Report, error) { state.request.Status = "submitted" state.submitted = true submitted++ - changed = true + submittedThisCycle++ + if submittedThisCycle == maxSubmissionsPerCycle { + break + } + } + if submittedThisCycle > 0 { + submittedSincePoll += submittedThisCycle + emit(options, startedAt, states, false) } - pollChanged, err := poll(runCtx, options, startedAt, states) - if err != nil { - if runCtx.Err() != nil { - return Report{}, fmt.Errorf("scenario %q timed out: %w", options.ScenarioName, runCtx.Err()) + pollChanged := false + shouldPoll := submitted == len(states) || + submittedThisCycle == 0 || + submittedSincePoll >= maxSubmissionsBetweenPolls + if shouldPoll { + var err error + pollChanged, err = poll(runCtx, options, startedAt, states) + if err != nil { + if runCtx.Err() != nil { + return Report{}, fmt.Errorf("scenario %q timed out: %w", options.ScenarioName, runCtx.Err()) + } + return Report{}, err } - return Report{}, err + submittedSincePoll = 0 } - changed = changed || pollChanged done := submitted == len(states) && allTerminal(states) - if changed || done { + if pollChanged || done { emit(options, startedAt, states, done) } if done { @@ -294,6 +314,7 @@ func poll(ctx context.Context, options Options, startedAt time.Time, states []re found[summary.SQID] = struct{}{} if applySummary(state, summary) { changed = true + state.historyDirty = true } } if nextToken == "" { @@ -314,18 +335,24 @@ func poll(ctx context.Context, options Options, startedAt time.Time, states []re if err == nil { if applySummary(state, summary) { changed = true + state.historyDirty = true } } } if isTerminal(state.request.Status) { state.terminalObserved = true - if !state.historyLoaded { - history, err := options.Gateway.History(ctx, state.request.SQID) - if err == nil { - state.request.History = append([]HistoryEvent(nil), history...) - state.historyLoaded = true + } + if state.historyDirty || (isTerminal(state.request.Status) && !state.historyLoaded) { + history, err := options.Gateway.History(ctx, state.request.SQID) + if err == nil { + if !equalHistory(state.request.History, history) { + state.request.History = cloneHistory(history) changed = true } + state.historyDirty = false + if isTerminal(state.request.Status) { + state.historyLoaded = true + } } } } @@ -411,3 +438,18 @@ func equalMetadata(left, right map[string]string) bool { } return true } + +func equalHistory(left, right []HistoryEvent) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i].TimestampMs != right[i].TimestampMs || + left[i].Status != right[i].Status || + left[i].LastError != right[i].LastError || + !equalMetadata(left[i].Metadata, right[i].Metadata) { + return false + } + } + return true +} diff --git a/sqsim/runner/runner_test.go b/sqsim/runner/runner_test.go index 9003b65b..5653c064 100644 --- a/sqsim/runner/runner_test.go +++ b/sqsim/runner/runner_test.go @@ -61,6 +61,7 @@ func TestRunSubmitsObservesAndVerifiesScenario(t *testing.T) { "sqsim://local/mixed/l1", }, gateway.uris) assert.True(t, snapshots[len(snapshots)-1].Done) + assert.True(t, hasLiveHistory(snapshots)) require.Len(t, report.Requests[1].History, 1) } @@ -83,6 +84,62 @@ func TestRunReportsExpectationMismatch(t *testing.T) { assert.False(t, report.Passed) } +func TestRunInterleavesSubmissionAndObservation(t *testing.T) { + lands := make([]*sqsim.LandBuilder, 0, maxSubmissionsBetweenPolls+1) + statuses := make(map[string][]string, maxSubmissionsBetweenPolls+1) + for i := 0; i < maxSubmissionsBetweenPolls+1; i++ { + lands = append(lands, + sqsim.NewLand(fmt.Sprintf("l%d", i+1)). + Queue("sqsim"). + Behavior(testBehavior()). + Expect(sqsim.RequestLanded), + ) + statuses[fmt.Sprintf("sqsim/%d", i+1)] = []string{"landed"} + } + scenario, err := sqsim.NewScenario(). + Timeout(time.Minute). + Land(lands...). + Build() + require.NoError(t, err) + + gateway := &testGateway{statuses: statuses} + observedPartialSubmission := false + report, err := Run(context.Background(), Options{ + ScenarioName: "many", + Scenario: scenario, + Gateway: gateway, + Clock: &testClock{now: time.Unix(0, 0)}, + PollInterval: time.Second, + Observer: ObserverFunc(func(snapshot Snapshot) { + submitted := 0 + for _, request := range snapshot.Requests { + if request.SQID != "" { + submitted++ + } + } + if submitted > 0 && submitted < len(snapshot.Requests) { + observedPartialSubmission = true + } + }), + }) + require.NoError(t, err) + assert.True(t, report.Passed) + assert.True(t, observedPartialSubmission) + + firstList := -1 + lastLand := -1 + for i, event := range gateway.events { + if event == "list" && firstList < 0 { + firstList = i + } + if event == "land" { + lastLand = i + } + } + assert.NotEqual(t, -1, firstList) + assert.Less(t, firstList, lastLand) +} + func testBehavior() *sqsim.BehaviorBuilder { return sqsim.NewBehavior(). BuildRunner(sqsim.SuccessfulBuildRunner()). @@ -90,9 +147,19 @@ func testBehavior() *sqsim.BehaviorBuilder { Merge(sqsim.SuccessfulMerge()) } +func hasLiveHistory(snapshots []Snapshot) bool { + for _, snapshot := range snapshots { + if !snapshot.Done && len(snapshot.Requests) > 0 && len(snapshot.Requests[0].History) > 0 { + return true + } + } + return false +} + type testGateway struct { mu sync.Mutex uris []string + events []string statuses map[string][]string polls map[string]int } @@ -101,12 +168,14 @@ func (g *testGateway) Land(_ context.Context, _ string, uri string) (string, err g.mu.Lock() defer g.mu.Unlock() g.uris = append(g.uris, uri) + g.events = append(g.events, "land") return fmt.Sprintf("sqsim/%d", len(g.uris)), nil } func (g *testGateway) List(_ context.Context, _ string, _, _ int64, _ string) ([]Summary, string, error) { g.mu.Lock() defer g.mu.Unlock() + g.events = append(g.events, "list") if g.polls == nil { g.polls = make(map[string]int) } diff --git a/sqsim/tui/BUILD.bazel b/sqsim/tui/BUILD.bazel new file mode 100644 index 00000000..e1686a7a --- /dev/null +++ b/sqsim/tui/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "stage.go", + "tui.go", + ], + importpath = "github.com/uber/submitqueue/sqsim/tui", + visibility = ["//visibility:public"], + deps = [ + "//sqsim/runner:go_default_library", + "@com_github_charmbracelet_bubbletea//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["tui_test.go"], + embed = [":go_default_library"], + deps = [ + "//sqsim/runner:go_default_library", + "@com_github_charmbracelet_bubbletea//:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/sqsim/tui/stage.go b/sqsim/tui/stage.go new file mode 100644 index 00000000..e10ca07f --- /dev/null +++ b/sqsim/tui/stage.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import "github.com/uber/submitqueue/sqsim/runner" + +type stage struct { + statuses map[string]struct{} +} + +var requestStages = []stage{ + {statuses: statusSet("validating", "validated")}, + {statuses: statusSet("batching", "batched")}, + {statuses: statusSet("scored")}, + {statuses: statusSet("speculating", "speculated", "waitingpath")}, + {statuses: statusSet("building", "built")}, + {statuses: statusSet("landing", "landed")}, +} + +func stageCells(request runner.Request, spinner string) [6]string { + current := -1 + reached := make([]bool, len(requestStages)) + for _, event := range request.History { + if index := stageIndex(event.Status); index >= 0 { + current = index + reached[index] = true + } + } + if index := stageIndex(request.Status); index >= 0 { + current = index + reached[index] = true + } + + var cells [6]string + for i := range requestStages { + switch { + case request.Status == "error" && i == current: + cells[i] = "x" + case request.Status == "cancelled" && i == current: + cells[i] = "x" + case i < current || (i == current && stageComplete(i, request.Status)): + cells[i] = "ok" + case i == current: + if request.Status == "building" { + cells[i] = spinner + } else { + cells[i] = ">" + } + case reached[i]: + cells[i] = "ok" + default: + cells[i] = "." + } + } + return cells +} + +func stageComplete(index int, status string) bool { + switch index { + case 0: + return status == "validated" || stageIndex(status) > index + case 1: + return status == "batched" || stageIndex(status) > index + case 2: + return status == "scored" || stageIndex(status) > index + case 3: + return status == "speculated" || status == "waitingpath" || stageIndex(status) > index + case 4: + return status == "built" || stageIndex(status) > index + case 5: + return status == "landed" + default: + return false + } +} + +func stageIndex(status string) int { + for i, stage := range requestStages { + if _, ok := stage.statuses[status]; ok { + return i + } + } + return -1 +} + +func statusSet(statuses ...string) map[string]struct{} { + set := make(map[string]struct{}, len(statuses)) + for _, status := range statuses { + set[status] = struct{}{} + } + return set +} diff --git a/sqsim/tui/tui.go b/sqsim/tui/tui.go new file mode 100644 index 00000000..8345d6ec --- /dev/null +++ b/sqsim/tui/tui.go @@ -0,0 +1,484 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tui renders an interactive SQSim scenario run. +package tui + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/uber/submitqueue/sqsim/runner" +) + +const ( + defaultWidth = 100 + defaultHeight = 24 + + ansiReset = "\x1b[0m" + ansiDimGray = "\x1b[2;90m" + ansiCyan = "\x1b[36m" + ansiGreen = "\x1b[32m" + ansiYellow = "\x1b[33m" + ansiRed = "\x1b[31m" + ansiBoldInverse = "\x1b[1;7m" +) + +var spinnerFrames = []string{"|", "/", "-", "\\"} + +// Execute runs a scenario and sends observations to the supplied observer. +type Execute func(context.Context, runner.Observer) (runner.Report, error) + +type resultMsg struct { + report runner.Report + err error +} + +type tickMsg time.Time + +type snapshotObserver struct { + ctx context.Context + snapshots chan runner.Snapshot +} + +func (o snapshotObserver) Observe(snapshot runner.Snapshot) { + for { + select { + case o.snapshots <- snapshot: + return + case <-o.ctx.Done(): + return + default: + } + select { + case <-o.snapshots: + case <-o.ctx.Done(): + return + default: + } + } +} + +type model struct { + cancel context.CancelFunc + scenario string + snapshots <-chan runner.Snapshot + results <-chan resultMsg + snapshot runner.Snapshot + report runner.Report + runErr error + finished bool + stopping bool + selected int + rowOffset int + historyOffset int + showDetails bool + width int + height int + frame int + now time.Time + buildStarted map[string]time.Time +} + +// Run executes a scenario in an alternate-screen terminal UI. +func Run(ctx context.Context, scenario string, input io.Reader, output io.Writer, execute Execute) (runner.Report, error) { + if execute == nil { + return runner.Report{}, fmt.Errorf("execute is required") + } + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + snapshots := make(chan runner.Snapshot, 1) + results := make(chan resultMsg, 1) + observer := snapshotObserver{ctx: runCtx, snapshots: snapshots} + go func() { + report, err := execute(runCtx, observer) + results <- resultMsg{report: report, err: err} + }() + + initial := model{ + cancel: cancel, + scenario: scenario, + snapshots: snapshots, + results: results, + showDetails: true, + width: defaultWidth, + height: defaultHeight, + buildStarted: make(map[string]time.Time), + } + program := tea.NewProgram(initial, tea.WithInput(input), tea.WithOutput(output), tea.WithAltScreen()) + final, err := program.Run() + if err != nil { + cancel() + return runner.Report{}, fmt.Errorf("run terminal UI: %w", err) + } + finalModel := final.(model) + if finalModel.runErr != nil { + return finalModel.report, finalModel.runErr + } + if !finalModel.finished { + return finalModel.report, context.Canceled + } + return finalModel.report, nil +} + +func (m model) Init() tea.Cmd { + return tea.Batch(waitSnapshot(m.snapshots), waitResult(m.results), nextTick()) +} + +func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + switch message := message.(type) { + case tea.WindowSizeMsg: + m.width = message.Width + m.height = message.Height + m.ensureVisible() + case runner.Snapshot: + m.snapshot = message + if m.now.Before(message.Now) { + m.now = message.Now + } + m.trackBuildStarts() + m.ensureVisible() + return m, waitSnapshot(m.snapshots) + case resultMsg: + m.report = message.report + m.runErr = message.err + m.finished = true + if m.stopping { + return m, tea.Quit + } + case tickMsg: + m.frame++ + m.now = time.Time(message) + return m, nextTick() + case tea.KeyMsg: + switch message.String() { + case "ctrl+c", "q": + if m.finished { + return m, tea.Quit + } + m.stopping = true + m.cancel() + case "up", "k": + if m.selected > 0 { + m.selected-- + m.historyOffset = 0 + m.ensureVisible() + } + case "down", "j": + if m.selected+1 < len(m.snapshot.Requests) { + m.selected++ + m.historyOffset = 0 + m.ensureVisible() + } + case "pgup": + m.historyOffset += max(1, m.detailLines()/2) + case "pgdown": + m.historyOffset = max(0, m.historyOffset-max(1, m.detailLines()/2)) + case "enter", "tab": + m.showDetails = !m.showDetails + m.ensureVisible() + } + } + return m, nil +} + +func (m model) View() string { + var view strings.Builder + fmt.Fprintf(&view, "SQSim %s %s\n", m.scenario, m.runState()) + fmt.Fprintf(&view, "Elapsed %s Requests %d Submitted %d/%d\n\n", + m.elapsed(), + len(m.snapshot.Requests), + m.submittedCount(), + len(m.snapshot.Requests), + ) + if len(m.snapshot.Requests) == 0 { + fmt.Fprintf(&view, " %s Starting fresh local stack\n", spinnerFrames[m.frame%len(spinnerFrames)]) + } else { + view.WriteString(" REQUEST VAL BAT SCO SPE BLD MRG STATUS BUILD\n") + start, end := m.visibleRows() + for i := start; i < end; i++ { + view.WriteString(m.requestRow(i, m.snapshot.Requests[i])) + view.WriteByte('\n') + } + if end < len(m.snapshot.Requests) { + fmt.Fprintf(&view, " ... %d more requests\n", len(m.snapshot.Requests)-end) + } + } + if m.showDetails && len(m.snapshot.Requests) > 0 { + view.WriteString("\n") + view.WriteString(m.detailView(m.snapshot.Requests[m.selected])) + } + view.WriteString("\n") + if m.finished { + view.WriteString("j/k select pgup/pgdn history enter details q quit") + } else if m.stopping { + view.WriteString("Stopping local stack...") + } else { + view.WriteString("j/k select pgup/pgdn history enter details q stop") + } + return view.String() +} + +func waitSnapshot(snapshots <-chan runner.Snapshot) tea.Cmd { + return func() tea.Msg { + return <-snapshots + } +} + +func waitResult(results <-chan resultMsg) tea.Cmd { + return func() tea.Msg { + return <-results + } +} + +func nextTick() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(now time.Time) tea.Msg { + return tickMsg(now) + }) +} + +func (m model) runState() string { + if m.stopping && !m.finished { + return "stopping" + } + if m.runErr != nil { + return "infrastructure failure" + } + if !m.finished { + return spinnerFrames[m.frame%len(spinnerFrames)] + " running" + } + if m.report.Passed { + return "PASS" + } + return "FAIL" +} + +func (m model) elapsed() time.Duration { + if m.snapshot.StartedAt.IsZero() { + return 0 + } + return m.currentTime().Sub(m.snapshot.StartedAt).Round(100 * time.Millisecond) +} + +func (m model) visibleRows() (int, int) { + capacity := m.rowCapacity() + start := min(m.rowOffset, max(0, len(m.snapshot.Requests)-capacity)) + return start, min(len(m.snapshot.Requests), start+capacity) +} + +func (m model) rowCapacity() int { + reserved := 8 + if m.showDetails { + reserved += m.detailLines() + } + return max(1, m.height-reserved) +} + +func (m model) detailLines() int { + return max(5, min(10, m.height/3)) +} + +func (m *model) ensureVisible() { + if len(m.snapshot.Requests) == 0 { + m.selected = 0 + m.rowOffset = 0 + return + } + m.selected = min(m.selected, len(m.snapshot.Requests)-1) + capacity := m.rowCapacity() + if m.selected < m.rowOffset { + m.rowOffset = m.selected + } + if m.selected >= m.rowOffset+capacity { + m.rowOffset = m.selected - capacity + 1 + } +} + +func (m model) requestRow(index int, request runner.Request) string { + cursor := " " + if index == m.selected { + cursor = ">" + } + stages := stageCells(request, spinnerFrames[m.frame%len(spinnerFrames)]) + build := "" + if request.Status == "building" { + build = m.buildElapsed(request).String() + } + status := request.Status + if status == "" { + status = "waiting" + } + if index == m.selected { + return style(fmt.Sprintf("%s %-16s %3s %3s %3s %3s %3s %3s %-18s %7s", + cursor, + truncate(request.Name, 16), + stages[0], + stages[1], + stages[2], + stages[3], + stages[4], + stages[5], + truncate(status, 18), + build, + ), ansiBoldInverse) + } + for i := range stages { + stages[i] = styleStage(stages[i]) + } + return fmt.Sprintf("%s %-16s %s %s %s %s %s %s %s %7s", + cursor, + truncate(request.Name, 16), + stages[0], + stages[1], + stages[2], + stages[3], + stages[4], + stages[5], + styleStatus(request, fmt.Sprintf("%-18s", truncate(status, 18))), + build, + ) +} + +func (m model) submittedCount() int { + submitted := 0 + for _, request := range m.snapshot.Requests { + if request.SQID != "" { + submitted++ + } + } + return submitted +} + +func (m model) detailView(request runner.Request) string { + var detail strings.Builder + fmt.Fprintf(&detail, "Details %s %s expected=%s\n", request.Name, request.SQID, request.Expected) + if request.LastError != "" { + fmt.Fprintf(&detail, "Error %s\n", truncate(request.LastError, max(20, m.width-9))) + } + keys := make([]string, 0, len(request.Metadata)) + for key := range request.Metadata { + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) > 0 { + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+request.Metadata[key]) + } + fmt.Fprintf(&detail, "Metadata %s\n", truncate(strings.Join(parts, " "), max(20, m.width-9))) + } + detail.WriteString("History\n") + start, end := historyWindow(len(request.History), m.historyOffset, m.detailLines()-3) + for _, event := range request.History[start:end] { + elapsed := time.Duration(0) + if !m.snapshot.StartedAt.IsZero() && event.TimestampMs > 0 { + elapsed = time.UnixMilli(event.TimestampMs).Sub(m.snapshot.StartedAt).Round(10 * time.Millisecond) + } + controller := event.Metadata["controller"] + fmt.Fprintf(&detail, " %8s %-16s %s\n", elapsed, event.Status, controller) + } + if start > 0 { + fmt.Fprintf(&detail, " ... %d earlier events\n", start) + } + return detail.String() +} + +func (m *model) trackBuildStarts() { + for _, request := range m.snapshot.Requests { + if request.Status != "building" { + continue + } + if _, ok := m.buildStarted[request.Name]; !ok { + m.buildStarted[request.Name] = m.snapshot.Now + } + } +} + +func (m model) buildElapsed(request runner.Request) time.Duration { + start := m.buildStarted[request.Name] + for _, event := range request.History { + if event.Status == "building" && event.TimestampMs > 0 { + start = time.UnixMilli(event.TimestampMs) + break + } + } + if start.IsZero() { + return 0 + } + return m.currentTime().Sub(start).Round(100 * time.Millisecond) +} + +func (m model) currentTime() time.Time { + if m.finished || m.now.Before(m.snapshot.Now) { + return m.snapshot.Now + } + return m.now +} + +func historyWindow(length, offset, capacity int) (int, int) { + if length == 0 || capacity <= 0 { + return 0, 0 + } + end := max(0, length-min(offset, length)) + start := max(0, end-capacity) + return start, end +} + +func truncate(value string, width int) string { + if width <= 0 || len(value) <= width { + return value + } + if width <= 3 { + return value[:width] + } + return value[:width-3] + "..." +} + +func styleStage(value string) string { + switch value { + case ".": + return style(fmt.Sprintf("%3s", value), ansiDimGray) + case "ok": + return style(fmt.Sprintf("%3s", value), ansiGreen) + case "x": + return style(fmt.Sprintf("%3s", value), ansiRed) + default: + return style(fmt.Sprintf("%3s", value), ansiCyan) + } +} + +func styleStatus(request runner.Request, value string) string { + switch { + case request.Status == "": + return style(value, ansiDimGray) + case request.Status == "landed": + return style(value, ansiGreen) + case request.Status == "error" || request.Status == "cancelled": + return style(value, ansiRed) + case request.LastError != "": + return style(value, ansiYellow) + default: + return style(value, ansiCyan) + } +} + +func style(value, code string) string { + return code + value + ansiReset +} diff --git a/sqsim/tui/tui_test.go b/sqsim/tui/tui_test.go new file mode 100644 index 00000000..b506d234 --- /dev/null +++ b/sqsim/tui/tui_test.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import ( + "context" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/sqsim/runner" +) + +func TestModelRendersRowsSpinnerAndHistory(t *testing.T) { + startedAt := time.Unix(100, 0) + m := model{ + cancel: func() {}, + scenario: "mixed", + showDetails: true, + width: 100, + height: 24, + now: startedAt.Add(3 * time.Second), + buildStarted: map[string]time.Time{"l1": startedAt.Add(time.Second)}, + snapshot: runner.Snapshot{ + Scenario: "mixed", + StartedAt: startedAt, + Now: startedAt.Add(3 * time.Second), + Requests: []runner.Request{{ + Name: "l1", + SQID: "sqsim/1", + Status: "building", + Expected: "landed", + History: []runner.HistoryEvent{ + {TimestampMs: startedAt.Add(time.Second).UnixMilli(), Status: "building", Metadata: map[string]string{"controller": "build"}}, + }, + }}, + }, + } + + view := m.View() + assert.Contains(t, view, "l1") + assert.Contains(t, view, "building") + assert.Contains(t, view, "2s") + assert.Contains(t, view, "History") + assert.Contains(t, view, "build") + + updated, _ := m.Update(tickMsg(startedAt.Add(4 * time.Second))) + assert.Contains(t, updated.(model).View(), "3s") +} + +func TestModelNavigationAndDetailToggle(t *testing.T) { + m := model{ + cancel: func() {}, + showDetails: true, + width: 100, + height: 12, + buildStarted: make(map[string]time.Time), + snapshot: runner.Snapshot{ + Requests: []runner.Request{{Name: "l1"}, {Name: "l2"}, {Name: "l3"}}, + }, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + assert.Equal(t, 1, m.selected) + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = updated.(model) + assert.False(t, m.showDetails) +} + +func TestModelShowsLiveSubmissionProgress(t *testing.T) { + m := model{ + cancel: func() {}, + width: 100, + height: 24, + buildStarted: make(map[string]time.Time), + snapshot: runner.Snapshot{ + Requests: []runner.Request{ + {Name: "l1", SQID: "sqsim/1", Status: "submitted"}, + {Name: "l2"}, + }, + }, + } + + view := m.View() + assert.Contains(t, view, "Submitted 1/2") + assert.Contains(t, view, "waiting") +} + +func TestSnapshotObserverKeepsLatestSnapshot(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + snapshots := make(chan runner.Snapshot, 1) + observer := snapshotObserver{ctx: ctx, snapshots: snapshots} + + observer.Observe(runner.Snapshot{Scenario: "old"}) + observer.Observe(runner.Snapshot{Scenario: "new"}) + + assert.Equal(t, "new", (<-snapshots).Scenario) +} + +func TestModelUsesSimpleStatusColors(t *testing.T) { + m := model{ + cancel: func() {}, + width: 100, + height: 24, + selected: 1, + buildStarted: make(map[string]time.Time), + snapshot: runner.Snapshot{ + Requests: []runner.Request{ + {Name: "landed", Status: "landed"}, + {Name: "selected", Status: "building"}, + {Name: "failed", Status: "error"}, + {Name: "waiting"}, + }, + }, + } + + view := m.View() + assert.Contains(t, view, ansiGreen) + assert.Contains(t, view, ansiRed) + assert.Contains(t, view, ansiDimGray) + assert.Contains(t, view, ansiBoldInverse) +} + +func TestHistoryWindowScrollsFromLatest(t *testing.T) { + start, end := historyWindow(10, 0, 3) + assert.Equal(t, 7, start) + assert.Equal(t, 10, end) + + start, end = historyWindow(10, 4, 3) + assert.Equal(t, 3, start) + assert.Equal(t, 6, end) +} + +func TestStageCellsUseHistory(t *testing.T) { + cells := stageCells(runner.Request{ + Status: "building", + History: []runner.HistoryEvent{ + {Status: "validating"}, + {Status: "validated"}, + {Status: "batched"}, + {Status: "scored"}, + {Status: "speculating"}, + {Status: "building"}, + }, + }, "/") + require.Len(t, cells, 6) + assert.Equal(t, []string{"ok", "ok", "ok", "ok", "/", "."}, cells[:]) +} + +func TestTruncate(t *testing.T) { + assert.Equal(t, "abcdef", truncate("abcdef", 6)) + assert.True(t, strings.HasSuffix(truncate("abcdefgh", 6), "...")) +} diff --git a/tool/sqsim/BUILD.bazel b/tool/sqsim/BUILD.bazel index 6ac1d17e..0c51891e 100644 --- a/tool/sqsim/BUILD.bazel +++ b/tool/sqsim/BUILD.bazel @@ -8,6 +8,7 @@ go_library( deps = [ "//sqsim/runner:go_default_library", "//sqsim/scenarios:go_default_library", + "//sqsim/tui:go_default_library", ], ) @@ -23,6 +24,7 @@ go_test( embed = [":go_default_library"], deps = [ "//sqsim/runner:go_default_library", + "//sqsim/tui:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", ], ) diff --git a/tool/sqsim/main.go b/tool/sqsim/main.go index d49e9e95..782007e8 100644 --- a/tool/sqsim/main.go +++ b/tool/sqsim/main.go @@ -24,13 +24,14 @@ import ( "github.com/uber/submitqueue/sqsim/runner" "github.com/uber/submitqueue/sqsim/scenarios" + "github.com/uber/submitqueue/sqsim/tui" ) func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } -func run(args []string, stdout, stderr io.Writer) int { +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if len(args) == 0 { printUsage(stderr) return 2 @@ -58,7 +59,7 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "%s is valid\n", args[1]) return 0 case "run": - return runScenarioCommand(args[1:], stdout, stderr) + return runScenarioCommand(args[1:], stdin, stdout, stderr) default: fmt.Fprintf(stderr, "unknown command %q\n", args[0]) printUsage(stderr) @@ -70,12 +71,13 @@ func printUsage(w io.Writer) { fmt.Fprintln(w, "usage:") fmt.Fprintln(w, " sqsim list") fmt.Fprintln(w, " sqsim validate ") - fmt.Fprintln(w, " sqsim run --headless") + fmt.Fprintln(w, " sqsim run [--headless]") } var runLocal = runner.RunLocal +var runTUI = tui.Run -func runScenarioCommand(args []string, stdout, stderr io.Writer) int { +func runScenarioCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if len(args) == 0 { printUsage(stderr) return 2 @@ -88,23 +90,33 @@ func runScenarioCommand(args []string, stdout, stderr io.Writer) int { if err := flags.Parse(args[1:]); err != nil { return 2 } - if !*headless { - fmt.Fprintln(stderr, "interactive mode is not available yet; pass --headless") - return 2 - } scenario, err := scenarios.Build(scenarioName) if err != nil { fmt.Fprintf(stderr, "invalid scenario: %v\n", err) return 2 } - report, err := runLocal(context.Background(), runner.LocalOptions{ - ScenarioName: scenarioName, - Scenario: scenario, - Observer: runner.NewTextObserver(stdout), - Stdout: stdout, - Stderr: stderr, - PollInterval: *pollInterval, - }) + var report runner.Report + if *headless { + report, err = runLocal(context.Background(), runner.LocalOptions{ + ScenarioName: scenarioName, + Scenario: scenario, + Observer: runner.NewTextObserver(stdout), + Stdout: stdout, + Stderr: stderr, + PollInterval: *pollInterval, + }) + } else { + report, err = runTUI(context.Background(), scenarioName, stdin, stdout, func(ctx context.Context, observer runner.Observer) (runner.Report, error) { + return runLocal(ctx, runner.LocalOptions{ + ScenarioName: scenarioName, + Scenario: scenario, + Observer: observer, + Stdout: io.Discard, + Stderr: io.Discard, + PollInterval: *pollInterval, + }) + }) + } if err != nil { fmt.Fprintf(stderr, "sqsim infrastructure failure: %v\n", err) return 2 diff --git a/tool/sqsim/main_test.go b/tool/sqsim/main_test.go index 2728f24c..6d0efe15 100644 --- a/tool/sqsim/main_test.go +++ b/tool/sqsim/main_test.go @@ -17,10 +17,12 @@ package main import ( "bytes" "context" + "io" "testing" "github.com/stretchr/testify/assert" "github.com/uber/submitqueue/sqsim/runner" + "github.com/uber/submitqueue/sqsim/tui" ) func TestRun(t *testing.T) { @@ -40,7 +42,7 @@ func TestRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := run(tt.args, &stdout, &stderr) + code := run(tt.args, &bytes.Buffer{}, &stdout, &stderr) assert.Equal(t, tt.wantCode, code) if tt.wantOutput != "" { assert.Equal(t, tt.wantOutput, stdout.String()) @@ -70,7 +72,24 @@ func TestRunScenarioCommandExitCodes(t *testing.T) { } var stdout bytes.Buffer var stderr bytes.Buffer - assert.Equal(t, tt.want, runScenarioCommand([]string{"happy-path", "--headless"}, &stdout, &stderr)) + assert.Equal(t, tt.want, runScenarioCommand([]string{"happy-path", "--headless"}, &bytes.Buffer{}, &stdout, &stderr)) }) } } + +func TestRunScenarioCommandUsesInteractiveTUIByDefault(t *testing.T) { + originalLocal := runLocal + originalTUI := runTUI + t.Cleanup(func() { + runLocal = originalLocal + runTUI = originalTUI + }) + + runTUI = func(_ context.Context, scenario string, _ io.Reader, _ io.Writer, execute tui.Execute) (runner.Report, error) { + assert.Equal(t, "happy-path", scenario) + return runner.Report{Passed: true}, nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + assert.Equal(t, 0, runScenarioCommand([]string{"happy-path"}, &bytes.Buffer{}, &stdout, &stderr)) +}