From 202c1ad16b78aa681cbe552164f2e9a9a7e574fa Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:04:57 -0600 Subject: [PATCH 01/48] feat(prompter): add accessible prompter support --- go.mod | 12 ++ go.sum | 29 +++- internal/prompter/prompter.go | 233 ++++++++++++++++++++++++++++- internal/prompter/prompter_test.go | 63 ++++++++ 4 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 internal/prompter/prompter_test.go diff --git a/go.mod b/go.mod index bea712a2d..f64430db7 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 + github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 + github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc github.com/cli/go-gh/v2 v2.12.0 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 @@ -64,12 +66,16 @@ require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alessio/shellescape v1.4.2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cli/browser v1.3.0 // indirect github.com/cli/shurcooL-graphql v0.0.4 // indirect @@ -82,6 +88,8 @@ require ( github.com/docker/cli v27.5.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect @@ -118,12 +126,16 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/oklog/ulid v1.3.1 // indirect diff --git a/go.sum b/go.sum index 2b5a31212..8ee1dcb09 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= @@ -95,22 +97,32 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 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/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g= github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= @@ -160,6 +172,10 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= @@ -340,6 +356,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -356,10 +374,16 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -550,6 +574,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 1d4b11cbc..7909bc8fd 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -2,9 +2,12 @@ package prompter import ( "fmt" + "os" "strings" "github.com/AlecAivazis/survey/v2" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" @@ -27,15 +30,233 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - return &surveyPrompter{ - prompter: ghPrompter.New(stdin, stdout, stderr), - stdin: stdin, - stdout: stdout, - stderr: stderr, - editorCmd: editorCmd, + accessiblePrompterValue := os.Getenv("GH_SCREENREADER_FRIENDLY") + switch accessiblePrompterValue { + case "", "false", "0": + return &surveyPrompter{ + prompter: ghPrompter.New(stdin, stdout, stderr), + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, + } + default: + return &huhPrompter{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, + accessible: true, + } } } +type huhPrompter struct { + stdin ghPrompter.FileReader + stdout ghPrompter.FileWriter + stderr ghPrompter.FileWriter + editorCmd string + accessible bool +} + +// IsAccessible returns true if the huhPrompter was created in accessible mode. +func (p *huhPrompter) IsAccessible() bool { + return p.accessible +} + +func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { + return huh.NewForm(groups...). + WithTheme(huh.ThemeBase16()). + WithAccessible(p.accessible). + WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) +} + +func (p *huhPrompter) Select(prompt, _ string, options []string) (int, error) { + var result int + formOptions := []huh.Option[int]{} + for i, o := range options { + formOptions = append(formOptions, huh.NewOption(o, i)) + } + + form := p.newForm( + huh.NewGroup( + huh.NewSelect[int](). + Title(prompt). + Value(&result). + Options(formOptions...), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { + var result []int + formOptions := make([]huh.Option[int], len(options)) + for i, o := range options { + formOptions[i] = huh.NewOption(o, i) + } + + form := p.newForm( + huh.NewGroup( + huh.NewMultiSelect[int](). + Title(prompt). + Value(&result). + Limit(len(options)). + Options(formOptions...), + ), + ) + + if err := form.Run(); err != nil { + return nil, err + } + + mid := len(result) / 2 + return result[:mid], nil +} + +func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { + result := defaultValue + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) Password(prompt string) (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + // This doesn't have any effect in accessible mode. + // EchoMode(huh.EchoModePassword), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) Confirm(prompt string, _ bool) (bool, error) { + var result bool + form := p.newForm( + huh.NewGroup( + huh.NewConfirm(). + Title(prompt). + Value(&result), + ), + ) + if err := form.Run(); err != nil { + return false, err + } + return result, nil +} + +func (p *huhPrompter) AuthToken() (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Paste your authentication token:"). + Validate(func(input string) error { + if input == "" { + return fmt.Errorf("token is required") + } + return nil + }). + Value(&result), + // This doesn't have any effect in accessible mode. + // EchoMode(huh.EchoModePassword), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)). + Validate(func(input string) error { + if input != requiredValue { + return fmt.Errorf("You entered: %q", input) + } + return nil + }). + Value(&result), + // This doesn't have any effect in accessible mode. + // EchoMode(huh.EchoModePassword), + ), + ) + + return form.Run() +} + +func (p *huhPrompter) InputHostname() (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Hostname:"). + Validate(ghinstance.HostnameValidator). + Value(&result), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(prompt). + Options( + huh.NewOption("Open Editor", "open"), + huh.NewOption("Skip", "skip"), + ). + Value(&result), + ), + ) + if err := form.Run(); err != nil { + return "", err + } + + if result == "skip" { + // TODO: loop if blank not allowed + if !blankAllowed && defaultValue == "" { + panic("blank not allowed and no default value") + } + return "", nil + } + + text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) + if err != nil { + return "", err + } + + // TODO: blank not allowed + if !blankAllowed && defaultValue == "" { + panic("blank not allowed and no default value") + } + + return text, nil +} + type surveyPrompter struct { prompter *ghPrompter.Prompter stdin ghPrompter.FileReader diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go new file mode 100644 index 000000000..99b8996ca --- /dev/null +++ b/internal/prompter/prompter_test.go @@ -0,0 +1,63 @@ +package prompter + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +func TestNewReturnsAccessiblePrompter(t *testing.T) { + editorCmd := "nothing" + ios, _, _, _ := iostreams.Test() + stdin := ios.In + stdout := ios.Out + stderr := ios.ErrOut + + t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "true") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + }) + + t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "1") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + }) + + t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "false") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + }) + + t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "0") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + }) + + t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + }) +} From 7b0c09541ddb41f8b355160bb413ede9eac42b8a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:52:26 -0600 Subject: [PATCH 02/48] feat(md prompter): md prompt respects blankAllowed Accessible prompter now respects blankAllowed and will not prompt for "skip" if blankAllowed is false. --- internal/prompter/prompter.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 7909bc8fd..c9af35fbf 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -221,23 +221,27 @@ func (p *huhPrompter) InputHostname() (string, error) { func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string + options := []huh.Option[string]{ + huh.NewOption("Open Editor", "open"), + } + if blankAllowed { + options = append(options, huh.NewOption("Skip", "skip")) + } + form := p.newForm( huh.NewGroup( huh.NewSelect[string](). Title(prompt). - Options( - huh.NewOption("Open Editor", "open"), - huh.NewOption("Skip", "skip"), - ). + Options(options...). Value(&result), ), ) + if err := form.Run(); err != nil { return "", err } if result == "skip" { - // TODO: loop if blank not allowed if !blankAllowed && defaultValue == "" { panic("blank not allowed and no default value") } From e973ee332dff7bcee46a99ab61fe5ff97dd9c12b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:58:20 -0600 Subject: [PATCH 03/48] fix(md prompter): accessible prompt allows blank Allow the accessible markdownEditor prompt to be blank when the blank comes from the result of an interactive session with an editor, even when blankAllowed is false. This behavior aligns the accessible prompter with the behavior of the current standard prompter. --- internal/prompter/prompter.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index c9af35fbf..3e37834db 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -253,11 +253,6 @@ func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed b return "", err } - // TODO: blank not allowed - if !blankAllowed && defaultValue == "" { - panic("blank not allowed and no default value") - } - return text, nil } From 92b1a8e0f04ba7772ef538048a433f27a1a18475 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:01:16 -0600 Subject: [PATCH 04/48] test(prompter): remove t.parallel calls t.Parallel() cannot be used when env vars are being set. --- internal/prompter/prompter_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index 99b8996ca..f0084e8fe 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -15,7 +15,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { stderr := ios.ErrOut t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "true") p := New(editorCmd, stdin, stdout, stderr) @@ -25,7 +24,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "1") p := New(editorCmd, stdin, stdout, stderr) @@ -35,7 +33,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "false") p := New(editorCmd, stdin, stdout, stderr) @@ -44,7 +41,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "0") p := New(editorCmd, stdin, stdout, stderr) @@ -53,7 +49,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "") p := New(editorCmd, stdin, stdout, stderr) From 88a98ea63a2694765c73b8887ca2ff905cb4c2c3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 11:58:10 -0600 Subject: [PATCH 05/48] feat(prompter): include `no` as false-y value Co-authored-by: Andy Feller --- internal/prompter/prompter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 3e37834db..abadda3dc 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -32,7 +32,7 @@ type Prompter interface { func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { accessiblePrompterValue := os.Getenv("GH_SCREENREADER_FRIENDLY") switch accessiblePrompterValue { - case "", "false", "0": + case "", "false", "0", "no": return &surveyPrompter{ prompter: ghPrompter.New(stdin, stdout, stderr), stdin: stdin, From f7de9e0c1198ef5cc932a2483d2e4230741a6abb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:53:44 -0600 Subject: [PATCH 06/48] test(prompter): `go-expect` based prompter tests --- go.mod | 2 + internal/prompter/prompter_test.go | 294 +++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/go.mod b/go.mod index f64430db7..85b0f88d4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.23.5 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 + github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 github.com/charmbracelet/bubbletea v1.3.4 @@ -31,6 +32,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.3.0 github.com/henvic/httpretty v0.1.4 + github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec github.com/in-toto/attestation v1.1.1 github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index f0084e8fe..1e66d7471 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -1,10 +1,19 @@ package prompter import ( + "fmt" + "io" + "os" + "strings" "testing" + "time" + "github.com/Netflix/go-expect" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/creack/pty" + "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewReturnsAccessiblePrompter(t *testing.T) { @@ -56,3 +65,288 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) } + +func TestAccessibleHuhprompter(t *testing.T) { + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second * 600), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + p := &huhPrompter{ + editorCmd: "", // intentionally empty to cause a failure. + accessible: true, + } + + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 + stdIn := os.Stdin + stdOut := os.Stdout + stdErr := os.Stderr + + t.Cleanup(func() { + os.Stdin = stdIn + os.Stdout = stdOut + os.Stderr = stdErr + }) + + os.Stdin = console.Tty() + os.Stdout = console.Tty() + os.Stderr = console.Tty() + + t.Run("Select", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Choose:") + require.NoError(t, err) + + // Select option 1 + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, 0, selectValue) + }) + + t.Run("MultiSelect", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Select options 1 and 2 + _, err = console.SendLine("1") + require.NoError(t, err) + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, []int{0, 1}, multiSelectValue) + }) + + t.Run("Input", func(t *testing.T) { + dummyText := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyText) + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", "") + require.NoError(t, err) + + assert.Equal(t, dummyText, inputValue) + }) + + t.Run("Password", func(t *testing.T) { + dummyPassword := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter password") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyPassword) + require.NoError(t, err) + }() + + passwordValue, err := p.Password("Enter password") + require.NoError(t, err) + require.Equal(t, dummyPassword, passwordValue) + }) + + t.Run("Confirm", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Confirm + _, err = console.SendLine("y") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, true, confirmValue) + }) + + t.Run("AuthToken", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine("12345abcdefg") + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, "12345abcdefg", authValue) + }) + + t.Run("ConfirmDeletion", func(t *testing.T) { + requiredValue := "test" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + + t.Run("InputHostname", func(t *testing.T) { + var inputValue string + hostname := "somethingdoesnotmatter.com" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Hostname:") + require.NoError(t, err) + + // Enter the hostname + _, err = console.SendLine(hostname) + require.NoError(t, err) + }() + + inputValue, err := p.InputHostname() + require.NoError(t, err) + require.Equal(t, hostname, inputValue) + }) + + t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter 2, to select "skip" + _, err = console.SendLine("2") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", "", true) + require.NoError(t, err) + require.Equal(t, "", inputValue) + }) + + t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shoudln't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor. + // Sending the input won't fail, so we expect no error here. + // See below though, since we expect the editor to fail to open. + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + // However, here we do expect an error because the editor program + // is intentionally empty and will fail. + inputValue, err := p.MarkdownEditor("How to edit?", "", false) + require.Error(t, err) + require.Equal(t, "", inputValue) + }) +} + +// failOnExpectError adds an observer that will fail the test in a standardised way +// if any expectation on the command output fails, without requiring an explicit +// assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnExpectError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithExpectObserver( + func(matchers []expect.Matcher, buf string, err error) { + t.Helper() + + if err == nil { + return + } + + if len(matchers) == 0 { + t.Fatalf("Error occurred while matching %q: %s\n", buf, err) + } + + var criteria []string + for _, matcher := range matchers { + criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) + } + t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) + }, + ) +} + +// failOnSendError adds an observer that will fail the test in a standardised way +// if any sending of input fails, without requiring an explicit assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnSendError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithSendObserver( + func(msg string, n int, err error) { + t.Helper() + + if err != nil { + t.Fatalf("Failed to send %q: %s\n", msg, err) + } + if len(msg) != n { + t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) + } + }, + ) +} + +// testCloser is a helper to fail the test if a Closer fails to close. +func testCloser(t testing.TB, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("Close failed: %s", err) + } +} From 94bbd26aab390c3f8929b040ec229484b9685bd3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:54:35 -0600 Subject: [PATCH 07/48] fix(prompter): rename huhprompter --- internal/prompter/prompter.go | 26 +++++++++++++------------- internal/prompter/prompter_test.go | 10 +++++----- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index abadda3dc..93d1d34c0 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -41,7 +41,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr editorCmd: editorCmd, } default: - return &huhPrompter{ + return &SpeechSynthesizerFriendlyPrompter{ stdin: stdin, stdout: stdout, stderr: stderr, @@ -51,7 +51,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } } -type huhPrompter struct { +type SpeechSynthesizerFriendlyPrompter struct { stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter @@ -60,18 +60,18 @@ type huhPrompter struct { } // IsAccessible returns true if the huhPrompter was created in accessible mode. -func (p *huhPrompter) IsAccessible() bool { +func (p *SpeechSynthesizerFriendlyPrompter) IsAccessible() bool { return p.accessible } -func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { +func (p *SpeechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). WithAccessible(p.accessible). WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } -func (p *huhPrompter) Select(prompt, _ string, options []string) (int, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} for i, o := range options { @@ -91,7 +91,7 @@ func (p *huhPrompter) Select(prompt, _ string, options []string) (int, error) { return result, err } -func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *SpeechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int formOptions := make([]huh.Option[int], len(options)) for i, o := range options { @@ -116,7 +116,7 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st return result[:mid], nil } -func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue form := p.newForm( huh.NewGroup( @@ -130,7 +130,7 @@ func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { return result, err } -func (p *huhPrompter) Password(prompt string) (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -146,7 +146,7 @@ func (p *huhPrompter) Password(prompt string) (string, error) { return result, err } -func (p *huhPrompter) Confirm(prompt string, _ bool) (bool, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { var result bool form := p.newForm( huh.NewGroup( @@ -161,7 +161,7 @@ func (p *huhPrompter) Confirm(prompt string, _ bool) (bool, error) { return result, nil } -func (p *huhPrompter) AuthToken() (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) AuthToken() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -183,7 +183,7 @@ func (p *huhPrompter) AuthToken() (string, error) { return result, err } -func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { +func (p *SpeechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { var result string form := p.newForm( huh.NewGroup( @@ -204,7 +204,7 @@ func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { return form.Run() } -func (p *huhPrompter) InputHostname() (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) InputHostname() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -219,7 +219,7 @@ func (p *huhPrompter) InputHostname() (string, error) { return result, err } -func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string options := []huh.Option[string]{ huh.NewOption("Open Editor", "open"), diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index 1e66d7471..e610b3a7c 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -28,8 +28,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { @@ -37,8 +37,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -87,7 +87,7 @@ func TestAccessibleHuhprompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) - p := &huhPrompter{ + p := &SpeechSynthesizerFriendlyPrompter{ editorCmd: "", // intentionally empty to cause a failure. accessible: true, } From 0d7fd36f11ead4d35d09b51a00ba70ac54d5131d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:58:44 -0600 Subject: [PATCH 08/48] test(prompter): replace assert with require --- internal/prompter/prompter_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index e610b3a7c..debaa4496 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -28,8 +28,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { @@ -37,8 +37,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -46,7 +46,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { @@ -54,7 +54,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { @@ -62,7 +62,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) } From e42af358392fae34eab89053c76e286a10653815 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:00:46 -0600 Subject: [PATCH 09/48] tests(prompter): rename huhprompter --- internal/prompter/prompter_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index debaa4496..c8f6dd213 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -23,22 +23,22 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { stdout := ios.Out stderr := ios.ErrOut - t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { + t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { t.Setenv("GH_SCREENREADER_FRIENDLY", "true") p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) - t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { + t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { t.Setenv("GH_SCREENREADER_FRIENDLY", "1") p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -66,7 +66,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) } -func TestAccessibleHuhprompter(t *testing.T) { +func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // Create a PTY and hook up a virtual terminal emulator ptm, pts, err := pty.Open() require.NoError(t, err) From e299b56c0f29713c853df03abc5ff5f1f306c155 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:08:36 -0600 Subject: [PATCH 10/48] test(prompter): remove needless variable declaration --- internal/prompter/prompter_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index c8f6dd213..5c4a45a2c 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -233,7 +233,6 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { }) t.Run("InputHostname", func(t *testing.T) { - var inputValue string hostname := "somethingdoesnotmatter.com" go func() { // Wait for prompt to appear From 8827803bd1d864f2a39076e60052c6dcdea4cb84 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:17:53 -0600 Subject: [PATCH 11/48] test(prompter): skip vt10x tests on Windows --- internal/prompter/prompter.go | 26 +- internal/prompter/prompter_test.go | 301 +---------------- ...eech_synthesizer_friendly_prompter_test.go | 302 ++++++++++++++++++ 3 files changed, 319 insertions(+), 310 deletions(-) create mode 100644 internal/prompter/speech_synthesizer_friendly_prompter_test.go diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 93d1d34c0..b9e832bd0 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -41,7 +41,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr editorCmd: editorCmd, } default: - return &SpeechSynthesizerFriendlyPrompter{ + return &speechSynthesizerFriendlyPrompter{ stdin: stdin, stdout: stdout, stderr: stderr, @@ -51,7 +51,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } } -type SpeechSynthesizerFriendlyPrompter struct { +type speechSynthesizerFriendlyPrompter struct { stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter @@ -60,18 +60,18 @@ type SpeechSynthesizerFriendlyPrompter struct { } // IsAccessible returns true if the huhPrompter was created in accessible mode. -func (p *SpeechSynthesizerFriendlyPrompter) IsAccessible() bool { +func (p *speechSynthesizerFriendlyPrompter) IsAccessible() bool { return p.accessible } -func (p *SpeechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { +func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). WithAccessible(p.accessible). WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } -func (p *SpeechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { +func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} for i, o := range options { @@ -91,7 +91,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []s return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int formOptions := make([]huh.Option[int], len(options)) for i, o := range options { @@ -116,7 +116,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults return result[:mid], nil } -func (p *SpeechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue form := p.newForm( huh.NewGroup( @@ -130,7 +130,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { +func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -146,7 +146,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Password(prompt string) (string, err return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { +func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { var result bool form := p.newForm( huh.NewGroup( @@ -161,7 +161,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool return result, nil } -func (p *SpeechSynthesizerFriendlyPrompter) AuthToken() (string, error) { +func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -183,7 +183,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) AuthToken() (string, error) { return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { +func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { var result string form := p.newForm( huh.NewGroup( @@ -204,7 +204,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return form.Run() } -func (p *SpeechSynthesizerFriendlyPrompter) InputHostname() (string, error) { +func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -219,7 +219,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) InputHostname() (string, error) { return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string options := []huh.Option[string]{ huh.NewOption("Open Editor", "open"), diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index 5c4a45a2c..b83a3e890 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -1,18 +1,9 @@ package prompter import ( - "fmt" - "io" - "os" - "strings" "testing" - "time" - "github.com/Netflix/go-expect" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/creack/pty" - "github.com/hinshun/vt10x" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -28,8 +19,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") + require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { @@ -37,8 +28,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") + require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -65,287 +56,3 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) } - -func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { - // Create a PTY and hook up a virtual terminal emulator - ptm, pts, err := pty.Open() - require.NoError(t, err) - - term := vt10x.New(vt10x.WithWriter(pts)) - - // Create a console via Expect that allows scripting against the terminal - consoleOpts := []expect.ConsoleOpt{ - expect.WithStdin(ptm), - expect.WithStdout(term), - expect.WithCloser(ptm, pts), - failOnExpectError(t), - failOnSendError(t), - expect.WithDefaultTimeout(time.Second * 600), - } - - console, err := expect.NewConsole(consoleOpts...) - require.NoError(t, err) - t.Cleanup(func() { testCloser(t, console) }) - - p := &SpeechSynthesizerFriendlyPrompter{ - editorCmd: "", // intentionally empty to cause a failure. - accessible: true, - } - - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 - stdIn := os.Stdin - stdOut := os.Stdout - stdErr := os.Stderr - - t.Cleanup(func() { - os.Stdin = stdIn - os.Stdout = stdOut - os.Stderr = stdErr - }) - - os.Stdin = console.Tty() - os.Stdout = console.Tty() - os.Stderr = console.Tty() - - t.Run("Select", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Choose:") - require.NoError(t, err) - - // Select option 1 - _, err = console.SendLine("1") - require.NoError(t, err) - }() - - selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) - require.NoError(t, err) - - assert.Equal(t, 0, selectValue) - }) - - t.Run("MultiSelect", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Select a number") - require.NoError(t, err) - - // Select options 1 and 2 - _, err = console.SendLine("1") - require.NoError(t, err) - _, err = console.SendLine("2") - require.NoError(t, err) - - // This confirms selections - _, err = console.SendLine("0") - require.NoError(t, err) - }() - - multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) - require.NoError(t, err) - - assert.Equal(t, []int{0, 1}, multiSelectValue) - }) - - t.Run("Input", func(t *testing.T) { - dummyText := "12345abcdefg" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Enter some characters") - require.NoError(t, err) - - // Enter a number - _, err = console.SendLine(dummyText) - require.NoError(t, err) - }() - - inputValue, err := p.Input("Enter some characters", "") - require.NoError(t, err) - - assert.Equal(t, dummyText, inputValue) - }) - - t.Run("Password", func(t *testing.T) { - dummyPassword := "12345abcdefg" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Enter password") - require.NoError(t, err) - - // Enter a number - _, err = console.SendLine(dummyPassword) - require.NoError(t, err) - }() - - passwordValue, err := p.Password("Enter password") - require.NoError(t, err) - require.Equal(t, dummyPassword, passwordValue) - }) - - t.Run("Confirm", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Are you sure") - require.NoError(t, err) - - // Confirm - _, err = console.SendLine("y") - require.NoError(t, err) - }() - - confirmValue, err := p.Confirm("Are you sure", false) - require.NoError(t, err) - require.Equal(t, true, confirmValue) - }) - - t.Run("AuthToken", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Paste your authentication token:") - require.NoError(t, err) - - // Enter a number - _, err = console.SendLine("12345abcdefg") - require.NoError(t, err) - }() - - authValue, err := p.AuthToken() - require.NoError(t, err) - require.Equal(t, "12345abcdefg", authValue) - }) - - t.Run("ConfirmDeletion", func(t *testing.T) { - requiredValue := "test" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) - require.NoError(t, err) - - // Confirm - _, err = console.SendLine(requiredValue) - require.NoError(t, err) - }() - - // An err indicates that the confirmation text sent did not match - err := p.ConfirmDeletion(requiredValue) - require.NoError(t, err) - }) - - t.Run("InputHostname", func(t *testing.T) { - hostname := "somethingdoesnotmatter.com" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Hostname:") - require.NoError(t, err) - - // Enter the hostname - _, err = console.SendLine(hostname) - require.NoError(t, err) - }() - - inputValue, err := p.InputHostname() - require.NoError(t, err) - require.Equal(t, hostname, inputValue) - }) - - t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("How to edit?") - require.NoError(t, err) - - // Enter 2, to select "skip" - _, err = console.SendLine("2") - require.NoError(t, err) - }() - - inputValue, err := p.MarkdownEditor("How to edit?", "", true) - require.NoError(t, err) - require.Equal(t, "", inputValue) - }) - - t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("How to edit?") - require.NoError(t, err) - - // Enter number 2 to select "skip". This shoudln't be allowed. - _, err = console.SendLine("2") - require.NoError(t, err) - - // Expect a notice to enter something valid since blank is disallowed. - _, err = console.ExpectString("invalid input. please try again") - require.NoError(t, err) - - // Send a 1 to select to open the editor. - // Sending the input won't fail, so we expect no error here. - // See below though, since we expect the editor to fail to open. - _, err = console.SendLine("1") - require.NoError(t, err) - }() - - // However, here we do expect an error because the editor program - // is intentionally empty and will fail. - inputValue, err := p.MarkdownEditor("How to edit?", "", false) - require.Error(t, err) - require.Equal(t, "", inputValue) - }) -} - -// failOnExpectError adds an observer that will fail the test in a standardised way -// if any expectation on the command output fails, without requiring an explicit -// assertion. -// -// Use WithRelaxedIO to disable this behaviour. -func failOnExpectError(t testing.TB) expect.ConsoleOpt { - t.Helper() - return expect.WithExpectObserver( - func(matchers []expect.Matcher, buf string, err error) { - t.Helper() - - if err == nil { - return - } - - if len(matchers) == 0 { - t.Fatalf("Error occurred while matching %q: %s\n", buf, err) - } - - var criteria []string - for _, matcher := range matchers { - criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) - } - t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) - }, - ) -} - -// failOnSendError adds an observer that will fail the test in a standardised way -// if any sending of input fails, without requiring an explicit assertion. -// -// Use WithRelaxedIO to disable this behaviour. -func failOnSendError(t testing.TB) expect.ConsoleOpt { - t.Helper() - return expect.WithSendObserver( - func(msg string, n int, err error) { - t.Helper() - - if err != nil { - t.Fatalf("Failed to send %q: %s\n", msg, err) - } - if len(msg) != n { - t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) - } - }, - ) -} - -// testCloser is a helper to fail the test if a Closer fails to close. -func testCloser(t testing.TB, closer io.Closer) { - t.Helper() - if err := closer.Close(); err != nil { - t.Errorf("Close failed: %s", err) - } -} diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go new file mode 100644 index 000000000..155258719 --- /dev/null +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -0,0 +1,302 @@ +//go:build !windows + +package prompter + +import ( + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/Netflix/go-expect" + "github.com/creack/pty" + "github.com/hinshun/vt10x" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second * 600), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + p := &speechSynthesizerFriendlyPrompter{ + editorCmd: "", // intentionally empty to cause a failure. + accessible: true, + } + + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 + stdIn := os.Stdin + stdOut := os.Stdout + stdErr := os.Stderr + + t.Cleanup(func() { + os.Stdin = stdIn + os.Stdout = stdOut + os.Stderr = stdErr + }) + + os.Stdin = console.Tty() + os.Stdout = console.Tty() + os.Stderr = console.Tty() + + t.Run("Select", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Choose:") + require.NoError(t, err) + + // Select option 1 + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, 0, selectValue) + }) + + t.Run("MultiSelect", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Select options 1 and 2 + _, err = console.SendLine("1") + require.NoError(t, err) + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, []int{0, 1}, multiSelectValue) + }) + + t.Run("Input", func(t *testing.T) { + dummyText := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyText) + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", "") + require.NoError(t, err) + + assert.Equal(t, dummyText, inputValue) + }) + + t.Run("Password", func(t *testing.T) { + dummyPassword := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter password") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyPassword) + require.NoError(t, err) + }() + + passwordValue, err := p.Password("Enter password") + require.NoError(t, err) + require.Equal(t, dummyPassword, passwordValue) + }) + + t.Run("Confirm", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Confirm + _, err = console.SendLine("y") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, true, confirmValue) + }) + + t.Run("AuthToken", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine("12345abcdefg") + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, "12345abcdefg", authValue) + }) + + t.Run("ConfirmDeletion", func(t *testing.T) { + requiredValue := "test" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + + t.Run("InputHostname", func(t *testing.T) { + hostname := "somethingdoesnotmatter.com" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Hostname:") + require.NoError(t, err) + + // Enter the hostname + _, err = console.SendLine(hostname) + require.NoError(t, err) + }() + + inputValue, err := p.InputHostname() + require.NoError(t, err) + require.Equal(t, hostname, inputValue) + }) + + t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter 2, to select "skip" + _, err = console.SendLine("2") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", "", true) + require.NoError(t, err) + require.Equal(t, "", inputValue) + }) + + t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shoudln't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor. + // Sending the input won't fail, so we expect no error here. + // See below though, since we expect the editor to fail to open. + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + // However, here we do expect an error because the editor program + // is intentionally empty and will fail. + inputValue, err := p.MarkdownEditor("How to edit?", "", false) + require.Error(t, err) + require.Equal(t, "", inputValue) + }) +} + +// failOnExpectError adds an observer that will fail the test in a standardised way +// if any expectation on the command output fails, without requiring an explicit +// assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnExpectError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithExpectObserver( + func(matchers []expect.Matcher, buf string, err error) { + t.Helper() + + if err == nil { + return + } + + if len(matchers) == 0 { + t.Fatalf("Error occurred while matching %q: %s\n", buf, err) + } + + var criteria []string + for _, matcher := range matchers { + criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) + } + t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) + }, + ) +} + +// failOnSendError adds an observer that will fail the test in a standardised way +// if any sending of input fails, without requiring an explicit assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnSendError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithSendObserver( + func(msg string, n int, err error) { + t.Helper() + + if err != nil { + t.Fatalf("Failed to send %q: %s\n", msg, err) + } + if len(msg) != n { + t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) + } + }, + ) +} + +// testCloser is a helper to fail the test if a Closer fails to close. +func testCloser(t testing.TB, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("Close failed: %s", err) + } +} From 88e6285b49d5a476e3e90c7d6867a91d346cb94a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:45:58 -0600 Subject: [PATCH 12/48] test(prompter): move to external package --- internal/prompter/prompter.go | 29 ++++------ internal/prompter/prompter_test.go | 58 ------------------- ...eech_synthesizer_friendly_prompter_test.go | 16 ++--- 3 files changed, 19 insertions(+), 84 deletions(-) delete mode 100644 internal/prompter/prompter_test.go diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index b9e832bd0..e764f7138 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/AlecAivazis/survey/v2" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/surveyext" @@ -42,33 +41,27 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } default: return &speechSynthesizerFriendlyPrompter{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - editorCmd: editorCmd, - accessible: true, + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, } } } type speechSynthesizerFriendlyPrompter struct { - stdin ghPrompter.FileReader - stdout ghPrompter.FileWriter - stderr ghPrompter.FileWriter - editorCmd string - accessible bool -} - -// IsAccessible returns true if the huhPrompter was created in accessible mode. -func (p *speechSynthesizerFriendlyPrompter) IsAccessible() bool { - return p.accessible + stdin ghPrompter.FileReader + stdout ghPrompter.FileWriter + stderr ghPrompter.FileWriter + editorCmd string } func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). - WithAccessible(p.accessible). - WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) + WithAccessible(true) + // Commented out because https://github.com/charmbracelet/huh/issues/612 + // WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go deleted file mode 100644 index b83a3e890..000000000 --- a/internal/prompter/prompter_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package prompter - -import ( - "testing" - - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/stretchr/testify/require" -) - -func TestNewReturnsAccessiblePrompter(t *testing.T) { - editorCmd := "nothing" - ios, _, _, _ := iostreams.Test() - stdin := ios.In - stdout := ios.Out - stderr := ios.ErrOut - - t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "true") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") - }) - - t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "1") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") - }) - - t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "false") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") - }) - - t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "0") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") - }) - - t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") - }) -} diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 155258719..2ccea38a4 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -1,6 +1,6 @@ //go:build !windows -package prompter +package prompter_test import ( "fmt" @@ -11,6 +11,7 @@ import ( "time" "github.com/Netflix/go-expect" + "github.com/cli/cli/v2/internal/prompter" "github.com/creack/pty" "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" @@ -38,13 +39,6 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) - p := &speechSynthesizerFriendlyPrompter{ - editorCmd: "", // intentionally empty to cause a failure. - accessible: true, - } - - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 stdIn := os.Stdin stdOut := os.Stdout stdErr := os.Stderr @@ -59,6 +53,11 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stdout = console.Tty() os.Stderr = console.Tty() + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 + t.Setenv("GH_SCREENREADER_FRIENDLY", "true") + p := prompter.New("", nil, nil, nil) + t.Run("Select", func(t *testing.T) { go func() { // Wait for prompt to appear @@ -150,6 +149,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.Equal(t, true, confirmValue) }) + // Need one that enters invalid input t.Run("AuthToken", func(t *testing.T) { go func() { // Wait for prompt to appear From 02fc12e7b74d3aab2060bdfd415f53b2b733dc7a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:51:54 -0600 Subject: [PATCH 13/48] fix(linter): linter errors --- go.mod | 2 +- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 85b0f88d4..9e2a2915b 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 - github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc @@ -74,6 +73,7 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/bubbletea v1.3.4 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 2ccea38a4..b8d4571f6 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -149,7 +149,8 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.Equal(t, true, confirmValue) }) - // Need one that enters invalid input + // TODO: Need one that enters invalid input + // TODO: write tests for control-c t.Run("AuthToken", func(t *testing.T) { go func() { // Wait for prompt to appear From 49ddacf5b85cff6d72faded445047a9e4485f298 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 13:06:31 -0600 Subject: [PATCH 14/48] docs(prompter): doc prompter interface --- internal/prompter/prompter.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index e764f7138..317e9094a 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -15,17 +15,31 @@ import ( //go:generate moq -rm -out prompter_mock.go . Prompter type Prompter interface { // generic prompts from go-gh - Select(string, string, []string) (int, error) + + // Select prompts the user to select an option from a list of options. + Select(prompt string, defaultValue string, options []string) (int, error) + // MultiSelect prompts the user to select one or more options from a list of options. MultiSelect(prompt string, defaults []string, options []string) ([]int, error) - Input(string, string) (string, error) - Password(string) (string, error) - Confirm(string, bool) (bool, error) + // Input prompts the user to enter a string value. + Input(prompt string, defaultValue string) (string, error) + // Password prompts the user to enter a password. + Password(prompt string) (string, error) + // Confirm prompts the user to confirm an action. + Confirm(prompt string, defaultValue bool) (bool, error) // gh specific prompts + + // AuthToken prompts the user to enter an authentication token. AuthToken() (string, error) - ConfirmDeletion(string) error + // ConfirmDeletion prompts the user to confirm deletion of a resource by + // typing the requiredValue. + ConfirmDeletion(requiredValue string) error + // InputHostname prompts the user to enter a hostname. InputHostname() (string, error) - MarkdownEditor(string, string, bool) (string, error) + // MarkdownEditor prompts the user to edit a markdown document in an editor. + // If blankAllowed is true, the user can skip the editor and an empty string + // will be returned. + MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { From a30df14b6abac659f2633b50b03e3b1de665598b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 13:08:16 -0600 Subject: [PATCH 15/48] refactor(prompter): rename env var for speech synthesizer friendly prompter --- internal/prompter/prompter.go | 2 +- .../prompter/speech_synthesizer_friendly_prompter_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 317e9094a..86408c645 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -43,7 +43,7 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - accessiblePrompterValue := os.Getenv("GH_SCREENREADER_FRIENDLY") + accessiblePrompterValue := os.Getenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") switch accessiblePrompterValue { case "", "false", "0", "no": return &surveyPrompter{ diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index b8d4571f6..21d126f75 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -39,6 +39,8 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 stdIn := os.Stdin stdOut := os.Stdout stdErr := os.Stderr @@ -53,9 +55,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stdout = console.Tty() os.Stderr = console.Tty() - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 - t.Setenv("GH_SCREENREADER_FRIENDLY", "true") + t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "true") p := prompter.New("", nil, nil, nil) t.Run("Select", func(t *testing.T) { From 5b0d49c6ecf9ef704a3f36d6701e81412b0e54f7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:06:40 -0600 Subject: [PATCH 16/48] test(prompter): more tests for bad input --- internal/prompter/prompter.go | 3 +- ...eech_synthesizer_friendly_prompter_test.go | 58 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 86408c645..f9146d8b7 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -174,6 +174,7 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { huh.NewGroup( huh.NewInput(). Title("Paste your authentication token:"). + // Note: if this validation fails, the prompt loops. Validate(func(input string) error { if input == "" { return fmt.Errorf("token is required") @@ -229,7 +230,7 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string options := []huh.Option[string]{ - huh.NewOption("Open Editor", "open"), + huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), "open"), } if blankAllowed { options = append(options, huh.NewOption("Skip", "skip")) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 21d126f75..119106f64 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -152,19 +152,45 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // TODO: Need one that enters invalid input // TODO: write tests for control-c t.Run("AuthToken", func(t *testing.T) { + dummyAuthToken := "12345abcdefg" go func() { // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) - // Enter a number - _, err = console.SendLine("12345abcdefg") + // Enter some dummy auth token + _, err = console.SendLine(dummyAuthToken) require.NoError(t, err) }() authValue, err := p.AuthToken() require.NoError(t, err) - require.Equal(t, "12345abcdefg", authValue) + require.Equal(t, dummyAuthToken, authValue) + }) + + t.Run("AuthToken - blank input returns error", func(t *testing.T) { + dummyAuthTokenForAfterFailure := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + + // Expect an error message + _, err = console.ExpectString("token is required") + require.NoError(t, err) + + // Now enter some dummy auth token to return control back to the test + _, err = console.SendLine(dummyAuthTokenForAfterFailure) + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, dummyAuthTokenForAfterFailure, authValue) }) t.Run("ConfirmDeletion", func(t *testing.T) { @@ -184,6 +210,32 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.NoError(t, err) }) + t.Run("ConfirmDeletion - bad input", func(t *testing.T) { + requiredValue := "test" + badInputValue := "garbage" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm with bad input + _, err = console.SendLine(badInputValue) + require.NoError(t, err) + + // Expect an error message and loop back to the prompt + _, err = console.ExpectString(fmt.Sprintf("You entered: %q", badInputValue)) + require.NoError(t, err) + + // Confirm with the correct input to return control back to the test + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + t.Run("InputHostname", func(t *testing.T) { hostname := "somethingdoesnotmatter.com" go func() { From 4cf048a8d17bf87ee9717756c9877fb5d5b77169 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:33:10 -0600 Subject: [PATCH 17/48] fix(prompter): input returns default when blank --- internal/prompter/prompter.go | 5 +++++ ...eech_synthesizer_friendly_prompter_test.go | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f9146d8b7..23f967e13 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -125,6 +125,7 @@ func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue + prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue) form := p.newForm( huh.NewGroup( huh.NewInput(). @@ -134,6 +135,10 @@ func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( ) err := form.Run() + + if result == "" { + return defaultValue, nil + } return result, err } diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 119106f64..8f41de973 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -116,6 +116,28 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { assert.Equal(t, dummyText, inputValue) }) + t.Run("Input - blank input returns default value", func(t *testing.T) { + dummyDefaultValue := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + + // Expect the default value to be returned + _, err = console.ExpectString(dummyDefaultValue) + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", dummyDefaultValue) + require.NoError(t, err) + + assert.Equal(t, dummyDefaultValue, inputValue) + }) + t.Run("Password", func(t *testing.T) { dummyPassword := "12345abcdefg" go func() { From 5c39e0bd10bc33808411e62ddda679131e9217b4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:52:25 -0600 Subject: [PATCH 18/48] fix(prompter): notes about Confirm default --- internal/prompter/prompter.go | 7 +++++-- ...eech_synthesizer_friendly_prompter_test.go | 21 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 23f967e13..2e6f7fe4f 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -158,8 +158,11 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err return result, err } -func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { - var result bool +func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { + // This is currently an inneffectual assignment because the value is + // not respected as the default in accessible mode. + // See https://github.com/charmbracelet/huh/issues/615 + result := defaultValue form := p.newForm( huh.NewGroup( huh.NewConfirm(). diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 8f41de973..4091db33a 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -171,8 +171,25 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.Equal(t, true, confirmValue) }) - // TODO: Need one that enters invalid input - // TODO: write tests for control-c + // This test currently fails because the value is + // not respected as the default in accessible mode. + // See https://github.com/charmbracelet/huh/issues/615 + // t.Run("Confirm - blank input returns default", func(t *testing.T) { + // go func() { + // // Wait for prompt to appear + // _, err := console.ExpectString("Are you sure") + // require.NoError(t, err) + + // // Enter nothing + // _, err = console.SendLine("") + // require.NoError(t, err) + // }() + + // confirmValue, err := p.Confirm("Are you sure", false) + // require.NoError(t, err) + // require.Equal(t, false, confirmValue) + // }) + t.Run("AuthToken", func(t *testing.T) { dummyAuthToken := "12345abcdefg" go func() { From 2e48cadf581b122860e4c9e2f83d0dbf588fe0a6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 15:05:23 -0600 Subject: [PATCH 19/48] fix(prompter): remove impossible condition --- internal/prompter/prompter.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2e6f7fe4f..fc2b3fe32 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -258,9 +258,6 @@ func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue } if result == "skip" { - if !blankAllowed && defaultValue == "" { - panic("blank not allowed and no default value") - } return "", nil } From 0b49522467c9122cb7644fb007f5d22db72874e3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 15:07:21 -0600 Subject: [PATCH 20/48] refactor(prompter): less magic strings --- internal/prompter/prompter.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index fc2b3fe32..67ae0fa6a 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -237,11 +237,13 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string + skipOption := "skip" + openOption := "open" options := []huh.Option[string]{ - huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), "open"), + huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), openOption), } if blankAllowed { - options = append(options, huh.NewOption("Skip", "skip")) + options = append(options, huh.NewOption("Skip", skipOption)) } form := p.newForm( @@ -257,10 +259,11 @@ func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue return "", err } - if result == "skip" { + if result == skipOption { return "", nil } + // openOption was selected text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) if err != nil { return "", err From f89700160b3fcc8f7c4d2f2ef3128063fe2cd8fc Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 15:08:17 -0600 Subject: [PATCH 21/48] doc(prompter): clarify comments --- internal/prompter/prompter.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 67ae0fa6a..0a42a8df9 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -160,7 +160,8 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { // This is currently an inneffectual assignment because the value is - // not respected as the default in accessible mode. + // not respected as the default in accessible mode. Leaving this in here + // because it may change in the future. // See https://github.com/charmbracelet/huh/issues/615 result := defaultValue form := p.newForm( From 5996f882fc9561b40d99d27cb5184f05ae72ced0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:40:29 -0600 Subject: [PATCH 22/48] doc(envs): speech synthesis prompter --- pkg/cmd/root/help_topic.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index db0ef098d..e0a1a8535 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -108,6 +108,9 @@ var HelpTopics = []helpTopic{ %[1]sGH_MDWIDTH%[1]s: default maximum width for markdown render wrapping. The max width of lines wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. + + %[1]sGH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER%[1]s: set to a truthy value to enable prompts that are + more compatible with speech synthesis based screen readers. `, "`"), }, { From 2a851e33e89509e37c6fba810944b11dd30113ff Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 11:59:05 -0600 Subject: [PATCH 23/48] test(prompter): fix race conditions --- ...eech_synthesizer_friendly_prompter_test.go | 140 +++++++++++++----- 1 file changed, 107 insertions(+), 33 deletions(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 4091db33a..e845febad 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "strings" + "sync" "testing" "time" @@ -56,10 +57,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stderr = console.Tty() t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "true") - p := prompter.New("", nil, nil, nil) + // Using echo as the editor command here because it will immediately exit + // and return no input. + p := prompter.New("echo", nil, nil, nil) + + var wg sync.WaitGroup t.Run("Select", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Choose:") require.NoError(t, err) @@ -71,12 +79,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) require.NoError(t, err) - assert.Equal(t, 0, selectValue) + + wg.Wait() }) t.Run("MultiSelect", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Select a number") require.NoError(t, err) @@ -94,13 +106,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) require.NoError(t, err) - assert.Equal(t, []int{0, 1}, multiSelectValue) + + wg.Wait() }) t.Run("Input", func(t *testing.T) { + wg.Add(1) + dummyText := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -112,13 +128,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", "") require.NoError(t, err) - assert.Equal(t, dummyText, inputValue) + + wg.Wait() }) t.Run("Input - blank input returns default value", func(t *testing.T) { + wg.Add(1) + dummyDefaultValue := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -134,13 +154,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", dummyDefaultValue) require.NoError(t, err) - assert.Equal(t, dummyDefaultValue, inputValue) + + wg.Wait() }) t.Run("Password", func(t *testing.T) { + wg.Add(1) + dummyPassword := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter password") require.NoError(t, err) @@ -153,10 +177,15 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { passwordValue, err := p.Password("Enter password") require.NoError(t, err) require.Equal(t, dummyPassword, passwordValue) + + wg.Wait() }) t.Run("Confirm", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Are you sure") require.NoError(t, err) @@ -169,30 +198,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { confirmValue, err := p.Confirm("Are you sure", false) require.NoError(t, err) require.Equal(t, true, confirmValue) + + wg.Wait() }) - // This test currently fails because the value is - // not respected as the default in accessible mode. - // See https://github.com/charmbracelet/huh/issues/615 - // t.Run("Confirm - blank input returns default", func(t *testing.T) { - // go func() { - // // Wait for prompt to appear - // _, err := console.ExpectString("Are you sure") - // require.NoError(t, err) - - // // Enter nothing - // _, err = console.SendLine("") - // require.NoError(t, err) - // }() - - // confirmValue, err := p.Confirm("Are you sure", false) - // require.NoError(t, err) - // require.Equal(t, false, confirmValue) - // }) - t.Run("AuthToken", func(t *testing.T) { + wg.Add(1) + dummyAuthToken := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -205,11 +220,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthToken, authValue) + + wg.Wait() }) t.Run("AuthToken - blank input returns error", func(t *testing.T) { + wg.Add(1) + dummyAuthTokenForAfterFailure := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -230,11 +250,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthTokenForAfterFailure, authValue) + + wg.Wait() }) t.Run("ConfirmDeletion", func(t *testing.T) { + wg.Add(1) + requiredValue := "test" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -247,12 +272,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) + + wg.Wait() }) t.Run("ConfirmDeletion - bad input", func(t *testing.T) { + wg.Add(1) + requiredValue := "test" badInputValue := "garbage" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -273,11 +303,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) + + wg.Wait() }) t.Run("InputHostname", func(t *testing.T) { + wg.Add(1) + hostname := "somethingdoesnotmatter.com" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Hostname:") require.NoError(t, err) @@ -290,10 +325,15 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.InputHostname() require.NoError(t, err) require.Equal(t, hostname, inputValue) + + wg.Wait() }) - t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { + t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -306,15 +346,21 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", "", true) require.NoError(t, err) require.Equal(t, "", inputValue) + + wg.Wait() }) - t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { + t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) { + wg.Add(1) + + defaultValue := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) - // Enter number 2 to select "skip". This shoudln't be allowed. + // Enter number 2 to select "skip". This shouldn't be allowed. _, err = console.SendLine("2") require.NoError(t, err) @@ -322,18 +368,46 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { _, err = console.ExpectString("invalid input. please try again") require.NoError(t, err) - // Send a 1 to select to open the editor. - // Sending the input won't fail, so we expect no error here. - // See below though, since we expect the editor to fail to open. + // Send a 1 to select to open the editor. This will immediately exit + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false) + require.NoError(t, err) + require.Equal(t, defaultValue, inputValue) + + wg.Wait() + }) + + t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) { + wg.Add(1) + + go func() { + defer wg.Done() + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shouldn't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor since skip is invalid and + // we need to return control back to the test. _, err = console.SendLine("1") require.NoError(t, err) }() - // However, here we do expect an error because the editor program - // is intentionally empty and will fail. inputValue, err := p.MarkdownEditor("How to edit?", "", false) - require.Error(t, err) + require.NoError(t, err) require.Equal(t, "", inputValue) + + wg.Wait() }) } From 0543aac53c564fb7ead6c44f741954594dd85494 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:54:03 -0600 Subject: [PATCH 24/48] test(prompter): add basic survey prompter test --- ...eech_synthesizer_friendly_prompter_test.go | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index e845febad..6970e3d33 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -411,6 +411,63 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { }) } +func TestSurveyPrompter(t *testing.T) { + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second * 600), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "") + t.Setenv("NO_COLOR", "1") + // Using echo as the editor command here because it will immediately exit + // and return no input. + p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) + + var wg sync.WaitGroup + + // This not a comprehensive test of the survey prompter, but it does + // demonstrate that the survey prompter is used when the speech + // synthesizer friendly prompter is disabled. + t.Run("Select uses survey prompter when speech synthesizer friendly prompter is disabled", func(t *testing.T) { + wg.Add(1) + + go func() { + defer wg.Done() + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Send a newline to select the first option + // Note: This would not work with the speech synthesizer friendly prompter + // because it would requires sending a 1 to select the first option. + // So it proves we are seeing a survey prompter. + _, err = console.SendLine("") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + assert.Equal(t, 0, selectValue) + + wg.Wait() + }) +} + // failOnExpectError adds an observer that will fail the test in a standardised way // if any expectation on the command output fails, without requiring an explicit // assertion. From 66407402c0fa7df7f8789642e0b76d1772d96c89 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:53:31 -0600 Subject: [PATCH 25/48] doc: comment typos and formatting Co-authored-by: Andy Feller --- internal/prompter/prompter.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 0a42a8df9..913b1a5e7 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -144,13 +144,12 @@ func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { var result string + // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( huh.NewInput(). Title(prompt). Value(&result), - // This doesn't have any effect in accessible mode. - // EchoMode(huh.EchoModePassword), ), ) @@ -159,7 +158,7 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err } func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { - // This is currently an inneffectual assignment because the value is + // This is currently an ineffectual assignment because the value is // not respected as the default in accessible mode. Leaving this in here // because it may change in the future. // See https://github.com/charmbracelet/huh/issues/615 @@ -202,6 +201,7 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { var result string + // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( huh.NewInput(). @@ -213,8 +213,6 @@ func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return nil }). Value(&result), - // This doesn't have any effect in accessible mode. - // EchoMode(huh.EchoModePassword), ), ) From c5ffb3cbfeec54c3882f589457ac2af561c551af Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:54:41 -0600 Subject: [PATCH 26/48] test: use example.com in tests --- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 6970e3d33..df8d2f445 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -310,7 +310,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { t.Run("InputHostname", func(t *testing.T) { wg.Add(1) - hostname := "somethingdoesnotmatter.com" + hostname := "example.com" go func() { defer wg.Done() // Wait for prompt to appear From fb80b5bd86cde6a009071b0b2e6d173e89abe3a9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:56:33 -0600 Subject: [PATCH 27/48] test(prompter): remove needless NO_COLOR set --- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index df8d2f445..74b053072 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -433,7 +433,6 @@ func TestSurveyPrompter(t *testing.T) { t.Cleanup(func() { testCloser(t, console) }) t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "") - t.Setenv("NO_COLOR", "1") // Using echo as the editor command here because it will immediately exit // and return no input. p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) From d8d3874778038fb5de581b7ed93b4e9bbaa4f185 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:38:54 -0600 Subject: [PATCH 28/48] fix(prompter): use os.lookupenv for accessible prompter --- internal/prompter/prompter.go | 24 ++++++++++--------- ...eech_synthesizer_friendly_prompter_test.go | 1 - 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 913b1a5e7..2c027c184 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -3,6 +3,7 @@ package prompter import ( "fmt" "os" + "slices" "strings" "github.com/AlecAivazis/survey/v2" @@ -43,17 +44,10 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - accessiblePrompterValue := os.Getenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") - switch accessiblePrompterValue { - case "", "false", "0", "no": - return &surveyPrompter{ - prompter: ghPrompter.New(stdin, stdout, stderr), - stdin: stdin, - stdout: stdout, - stderr: stderr, - editorCmd: editorCmd, - } - default: + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") + falseyValues := []string{"false", "0", "no", ""} + + if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) { return &speechSynthesizerFriendlyPrompter{ stdin: stdin, stdout: stdout, @@ -61,6 +55,14 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr editorCmd: editorCmd, } } + + return &surveyPrompter{ + prompter: ghPrompter.New(stdin, stdout, stderr), + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, + } } type speechSynthesizerFriendlyPrompter struct { diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 74b053072..64e9ac2fe 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -432,7 +432,6 @@ func TestSurveyPrompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) - t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "") // Using echo as the editor command here because it will immediately exit // and return no input. p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) From ef58e627f9d2cd3698bba857d6eca1450278dbb5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:44:14 -0600 Subject: [PATCH 29/48] test(prompter): timeout for tests is 1s --- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 64e9ac2fe..d461bf1e4 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -33,7 +33,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { expect.WithCloser(ptm, pts), failOnExpectError(t), failOnSendError(t), - expect.WithDefaultTimeout(time.Second * 600), + expect.WithDefaultTimeout(time.Second), } console, err := expect.NewConsole(consoleOpts...) From c4be95afd962945d123a760101e6d8b26aecd7c3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:58:50 -0600 Subject: [PATCH 30/48] refactor(prompter): remove unused variable --- internal/prompter/prompter.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2c027c184..2595463e0 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -202,7 +202,6 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { } func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { - var result string // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( @@ -213,8 +212,7 @@ func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return fmt.Errorf("You entered: %q", input) } return nil - }). - Value(&result), + }), ), ) From 8821f77fbbe97bf3f16ad70a7ef6e1471a948da0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:00:02 -0600 Subject: [PATCH 31/48] doc(prompter): remove senseless comment --- internal/prompter/prompter.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2595463e0..f1900c751 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -202,7 +202,6 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { } func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { - // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( huh.NewInput(). From 9cf341302eac404813eef1de4e45d8605a8985e0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:03:55 -0600 Subject: [PATCH 32/48] refactor(prompter): explicit return values --- internal/prompter/prompter.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f1900c751..556fa6f4c 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -156,7 +156,11 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err ) err := form.Run() - return result, err + if err != nil { + return "", err + } + + return result, nil } func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { @@ -230,7 +234,10 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { ) err := form.Run() - return result, err + if err != nil { + return "", err + } + return result, nil } func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { From 19387b84187bef5edf437f4a46399843979e955e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:52:39 -0600 Subject: [PATCH 33/48] fix(prompter): rename GH_ACCESSIBLE_PROMPTER --- internal/prompter/prompter.go | 2 +- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 2 +- pkg/cmd/root/help_topic.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 556fa6f4c..bf5655846 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -44,7 +44,7 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") falseyValues := []string{"false", "0", "no", ""} if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) { diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index d461bf1e4..20935a566 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -56,7 +56,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stdout = console.Tty() os.Stderr = console.Tty() - t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "true") + t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") // Using echo as the editor command here because it will immediately exit // and return no input. p := prompter.New("echo", nil, nil, nil) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index e0a1a8535..f7a827dcd 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -109,7 +109,7 @@ var HelpTopics = []helpTopic{ wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. - %[1]sGH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER%[1]s: set to a truthy value to enable prompts that are + %[1]sGH_ACCESSIBLE_PROMPTER%[1]s: set to a truthy value to enable prompts that are more compatible with speech synthesis based screen readers. `, "`"), }, From fa03157bebce3d0578d934a81e67d9b9acf7188d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:04:04 -0600 Subject: [PATCH 34/48] doc(help): label GH_ACCESSIBLE_PROMPTER as preview --- pkg/cmd/root/help_topic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index f7a827dcd..1a0934bb5 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -109,7 +109,7 @@ var HelpTopics = []helpTopic{ wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. - %[1]sGH_ACCESSIBLE_PROMPTER%[1]s: set to a truthy value to enable prompts that are + %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are more compatible with speech synthesis based screen readers. `, "`"), }, From d230b08c4367266c60a3c3618b208d234d0a9ffb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:09:15 -0600 Subject: [PATCH 35/48] test(prompter): re-add skipped test for accessible confirm default --- ...eech_synthesizer_friendly_prompter_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 20935a566..548825707 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -202,6 +202,26 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { wg.Wait() }) + // This test currently fails because the value is + // not respected as the default in accessible mode. + // See https://github.com/charmbracelet/huh/issues/615 + t.Run("Confirm - blank input returns default", func(t *testing.T) { + t.Skip("Skipped due to https://github.com/charmbracelet/huh/issues/615") + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, false, confirmValue) + }) + t.Run("AuthToken", func(t *testing.T) { wg.Add(1) From 3b2e7f7f712b473dc4378b56d907f98c80255931 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 08:13:21 -0600 Subject: [PATCH 36/48] chore: go mod tidy --- go.mod | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3a800505d..c44b83a20 100644 --- a/go.mod +++ b/go.mod @@ -126,7 +126,8 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect From a34c9ea79937f4cb7b2d01ee4e60d495d86d7abe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 08:20:07 -0600 Subject: [PATCH 37/48] doc(prompter env): accessible prompter includes braille reader Co-authored-by: Andy Feller --- pkg/cmd/root/help_topic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index dc39fd8d2..b85d64ca3 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -113,7 +113,7 @@ var HelpTopics = []helpTopic{ not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are - more compatible with speech synthesis based screen readers. + more compatible with speech synthesis and braille screen readers. `, "`"), }, { From 8fc8486af5ff47730c0f5021d16a6009377849d6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 08:24:54 -0600 Subject: [PATCH 38/48] refactor(prompter): rename speechSynthesizerFriendlyPrompter to accessiblePrompter --- ...er_test.go => accessible_prompter_test.go} | 10 ++++---- internal/prompter/prompter.go | 24 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) rename internal/prompter/{speech_synthesizer_friendly_prompter_test.go => accessible_prompter_test.go} (97%) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/accessible_prompter_test.go similarity index 97% rename from internal/prompter/speech_synthesizer_friendly_prompter_test.go rename to internal/prompter/accessible_prompter_test.go index 548825707..fcb806641 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { +func TestAccessiblePrompter(t *testing.T) { // Create a PTY and hook up a virtual terminal emulator ptm, pts, err := pty.Open() require.NoError(t, err) @@ -459,9 +459,9 @@ func TestSurveyPrompter(t *testing.T) { var wg sync.WaitGroup // This not a comprehensive test of the survey prompter, but it does - // demonstrate that the survey prompter is used when the speech - // synthesizer friendly prompter is disabled. - t.Run("Select uses survey prompter when speech synthesizer friendly prompter is disabled", func(t *testing.T) { + // demonstrate that the survey prompter is used when the + // accessible prompter is disabled. + t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) { wg.Add(1) go func() { @@ -471,7 +471,7 @@ func TestSurveyPrompter(t *testing.T) { require.NoError(t, err) // Send a newline to select the first option - // Note: This would not work with the speech synthesizer friendly prompter + // Note: This would not work with the accessible prompter // because it would requires sending a 1 to select the first option. // So it proves we are seeing a survey prompter. _, err = console.SendLine("") diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index bf5655846..f3a87c475 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -48,7 +48,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr falseyValues := []string{"false", "0", "no", ""} if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) { - return &speechSynthesizerFriendlyPrompter{ + return &accessiblePrompter{ stdin: stdin, stdout: stdout, stderr: stderr, @@ -65,14 +65,14 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } } -type speechSynthesizerFriendlyPrompter struct { +type accessiblePrompter struct { stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter editorCmd string } -func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { +func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). WithAccessible(true) @@ -80,7 +80,7 @@ func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.F // WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } -func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { +func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} for i, o := range options { @@ -100,7 +100,7 @@ func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []s return result, err } -func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int formOptions := make([]huh.Option[int], len(options)) for i, o := range options { @@ -125,7 +125,7 @@ func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults return result[:mid], nil } -func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue) form := p.newForm( @@ -144,7 +144,7 @@ func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( return result, err } -func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { +func (p *accessiblePrompter) Password(prompt string) (string, error) { var result string // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( @@ -163,7 +163,7 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err return result, nil } -func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { +func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { // This is currently an ineffectual assignment because the value is // not respected as the default in accessible mode. Leaving this in here // because it may change in the future. @@ -182,7 +182,7 @@ func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue return result, nil } -func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { +func (p *accessiblePrompter) AuthToken() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -205,7 +205,7 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { return result, err } -func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { +func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error { form := p.newForm( huh.NewGroup( huh.NewInput(). @@ -222,7 +222,7 @@ func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return form.Run() } -func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { +func (p *accessiblePrompter) InputHostname() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -240,7 +240,7 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { return result, nil } -func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string skipOption := "skip" openOption := "open" From 2f5e8965355bd120364cccb52e6f973a6e85718c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:18:53 -0600 Subject: [PATCH 39/48] fix(prompter): update `huh` and fix tests --- go.mod | 6 +++--- go.sum | 6 ++++++ internal/prompter/accessible_prompter_test.go | 4 ---- internal/prompter/prompter.go | 7 +------ 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index c44b83a20..e0f76dce8 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 - github.com/charmbracelet/huh v0.6.0 + github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc github.com/cli/go-gh/v2 v2.12.0 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 @@ -71,8 +71,8 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect - github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect github.com/charmbracelet/bubbletea v1.3.4 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect diff --git a/go.sum b/go.sum index 0c23b57d1..bf7f39142 100644 --- a/go.sum +++ b/go.sum @@ -98,12 +98,16 @@ github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gL github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -112,6 +116,8 @@ github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25S github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s= github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= +github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 h1:uOnMxWghHfEYm2DPMeIHHAEirV/TduBVC9ZRXGcX9Q8= +github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5/go.mod h1:xl27E/xNaX3WwdkqpvBwjJcGWhupkU52CWLC5hReBTw= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index fcb806641..0b0f6844f 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -202,11 +202,7 @@ func TestAccessiblePrompter(t *testing.T) { wg.Wait() }) - // This test currently fails because the value is - // not respected as the default in accessible mode. - // See https://github.com/charmbracelet/huh/issues/615 t.Run("Confirm - blank input returns default", func(t *testing.T) { - t.Skip("Skipped due to https://github.com/charmbracelet/huh/issues/615") go func() { // Wait for prompt to appear _, err := console.ExpectString("Are you sure") diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f3a87c475..d50150098 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -121,8 +121,7 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio return nil, err } - mid := len(result) / 2 - return result[:mid], nil + return result, nil } func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { @@ -164,10 +163,6 @@ func (p *accessiblePrompter) Password(prompt string) (string, error) { } func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { - // This is currently an ineffectual assignment because the value is - // not respected as the default in accessible mode. Leaving this in here - // because it may change in the future. - // See https://github.com/charmbracelet/huh/issues/615 result := defaultValue form := p.newForm( huh.NewGroup( From fab0de5583da0bc9373372a9049159162fb009a0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:37:48 -0600 Subject: [PATCH 40/48] fix(prompter): pass io to `huh` and refactor tests --- internal/prompter/accessible_prompter_test.go | 220 +++++++----------- internal/prompter/prompter.go | 6 +- 2 files changed, 84 insertions(+), 142 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 0b0f6844f..ed96cd65c 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -5,9 +5,7 @@ package prompter_test import ( "fmt" "io" - "os" "strings" - "sync" "testing" "time" @@ -20,54 +18,11 @@ import ( ) func TestAccessiblePrompter(t *testing.T) { - // Create a PTY and hook up a virtual terminal emulator - ptm, pts, err := pty.Open() - require.NoError(t, err) - - term := vt10x.New(vt10x.WithWriter(pts)) - - // Create a console via Expect that allows scripting against the terminal - consoleOpts := []expect.ConsoleOpt{ - expect.WithStdin(ptm), - expect.WithStdout(term), - expect.WithCloser(ptm, pts), - failOnExpectError(t), - failOnSendError(t), - expect.WithDefaultTimeout(time.Second), - } - - console, err := expect.NewConsole(consoleOpts...) - require.NoError(t, err) - t.Cleanup(func() { testCloser(t, console) }) - - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 - stdIn := os.Stdin - stdOut := os.Stdout - stdErr := os.Stderr - - t.Cleanup(func() { - os.Stdin = stdIn - os.Stdout = stdOut - os.Stderr = stdErr - }) - - os.Stdin = console.Tty() - os.Stdout = console.Tty() - os.Stderr = console.Tty() - - t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") - // Using echo as the editor command here because it will immediately exit - // and return no input. - p := prompter.New("echo", nil, nil, nil) - - var wg sync.WaitGroup - t.Run("Select", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Choose:") require.NoError(t, err) @@ -80,15 +35,13 @@ func TestAccessiblePrompter(t *testing.T) { selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) require.NoError(t, err) assert.Equal(t, 0, selectValue) - - wg.Wait() }) t.Run("MultiSelect", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Select a number") require.NoError(t, err) @@ -107,16 +60,14 @@ func TestAccessiblePrompter(t *testing.T) { multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) require.NoError(t, err) assert.Equal(t, []int{0, 1}, multiSelectValue) - - wg.Wait() }) t.Run("Input", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyText := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -129,16 +80,14 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", "") require.NoError(t, err) assert.Equal(t, dummyText, inputValue) - - wg.Wait() }) t.Run("Input - blank input returns default value", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyDefaultValue := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -155,16 +104,14 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", dummyDefaultValue) require.NoError(t, err) assert.Equal(t, dummyDefaultValue, inputValue) - - wg.Wait() }) t.Run("Password", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyPassword := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter password") require.NoError(t, err) @@ -177,15 +124,13 @@ func TestAccessiblePrompter(t *testing.T) { passwordValue, err := p.Password("Enter password") require.NoError(t, err) require.Equal(t, dummyPassword, passwordValue) - - wg.Wait() }) t.Run("Confirm", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Are you sure") require.NoError(t, err) @@ -198,11 +143,12 @@ func TestAccessiblePrompter(t *testing.T) { confirmValue, err := p.Confirm("Are you sure", false) require.NoError(t, err) require.Equal(t, true, confirmValue) - - wg.Wait() }) t.Run("Confirm - blank input returns default", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + go func() { // Wait for prompt to appear _, err := console.ExpectString("Are you sure") @@ -219,11 +165,11 @@ func TestAccessiblePrompter(t *testing.T) { }) t.Run("AuthToken", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyAuthToken := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -236,16 +182,14 @@ func TestAccessiblePrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthToken, authValue) - - wg.Wait() }) t.Run("AuthToken - blank input returns error", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyAuthTokenForAfterFailure := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -266,16 +210,14 @@ func TestAccessiblePrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthTokenForAfterFailure, authValue) - - wg.Wait() }) t.Run("ConfirmDeletion", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) requiredValue := "test" go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -288,17 +230,15 @@ func TestAccessiblePrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) - - wg.Wait() }) t.Run("ConfirmDeletion - bad input", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) requiredValue := "test" badInputValue := "garbage" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -319,16 +259,14 @@ func TestAccessiblePrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) - - wg.Wait() }) t.Run("InputHostname", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) hostname := "example.com" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Hostname:") require.NoError(t, err) @@ -341,15 +279,13 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.InputHostname() require.NoError(t, err) require.Equal(t, hostname, inputValue) - - wg.Wait() }) t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -362,16 +298,14 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", "", true) require.NoError(t, err) require.Equal(t, "", inputValue) - - wg.Wait() }) t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) defaultValue := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -392,15 +326,13 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false) require.NoError(t, err) require.Equal(t, defaultValue, inputValue) - - wg.Wait() }) t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -422,46 +354,18 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", "", false) require.NoError(t, err) require.Equal(t, "", inputValue) - - wg.Wait() }) } func TestSurveyPrompter(t *testing.T) { - // Create a PTY and hook up a virtual terminal emulator - ptm, pts, err := pty.Open() - require.NoError(t, err) - - term := vt10x.New(vt10x.WithWriter(pts)) - - // Create a console via Expect that allows scripting against the terminal - consoleOpts := []expect.ConsoleOpt{ - expect.WithStdin(ptm), - expect.WithStdout(term), - expect.WithCloser(ptm, pts), - failOnExpectError(t), - failOnSendError(t), - expect.WithDefaultTimeout(time.Second * 600), - } - - console, err := expect.NewConsole(consoleOpts...) - require.NoError(t, err) - t.Cleanup(func() { testCloser(t, console) }) - - // Using echo as the editor command here because it will immediately exit - // and return no input. - p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) - - var wg sync.WaitGroup - // This not a comprehensive test of the survey prompter, but it does // demonstrate that the survey prompter is used when the // accessible prompter is disabled. t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestSurveyPrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Select a number") require.NoError(t, err) @@ -477,11 +381,49 @@ func TestSurveyPrompter(t *testing.T) { selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) require.NoError(t, err) assert.Equal(t, 0, selectValue) - - wg.Wait() }) } +func newTestVirtualTerminal(t testing.TB) *expect.Console { + t.Helper() + + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + return console +} + +func newTestAcessiblePrompter(t testing.TB, console *expect.Console) prompter.Prompter { + t.Helper() + + t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") + return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) +} + +func newTestSurveyPrompter(t testing.TB, console *expect.Console) prompter.Prompter { + t.Helper() + + t.Setenv("GH_ACCESSIBLE_PROMPTER", "false") + return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) +} + // failOnExpectError adds an observer that will fail the test in a standardised way // if any expectation on the command output fails, without requiring an explicit // assertion. diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index d50150098..6cdbc9a87 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -75,9 +75,9 @@ type accessiblePrompter struct { func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). - WithAccessible(true) - // Commented out because https://github.com/charmbracelet/huh/issues/612 - // WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) + WithAccessible(true). + WithInput(p.stdin). + WithOutput(p.stdout) } func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) { From 46150697735ba6965e851a68456533b2af8e5035 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:43:18 -0600 Subject: [PATCH 41/48] chore: go mod tidy --- go.sum | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/go.sum b/go.sum index bf7f39142..5441c96c5 100644 --- a/go.sum +++ b/go.sum @@ -96,16 +96,12 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= @@ -114,8 +110,6 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g= github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s= -github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= -github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 h1:uOnMxWghHfEYm2DPMeIHHAEirV/TduBVC9ZRXGcX9Q8= github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5/go.mod h1:xl27E/xNaX3WwdkqpvBwjJcGWhupkU52CWLC5hReBTw= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys= @@ -124,8 +118,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= From 47d603221d4cafebfc79619eb2296b8ea2328037 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:37:36 -0600 Subject: [PATCH 42/48] test(prompter): use *testing.T instead --- internal/prompter/accessible_prompter_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index ed96cd65c..e80c3085e 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -384,7 +384,7 @@ func TestSurveyPrompter(t *testing.T) { }) } -func newTestVirtualTerminal(t testing.TB) *expect.Console { +func newTestVirtualTerminal(t *testing.T) *expect.Console { t.Helper() // Create a PTY and hook up a virtual terminal emulator @@ -410,14 +410,14 @@ func newTestVirtualTerminal(t testing.TB) *expect.Console { return console } -func newTestAcessiblePrompter(t testing.TB, console *expect.Console) prompter.Prompter { +func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter { t.Helper() t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) } -func newTestSurveyPrompter(t testing.TB, console *expect.Console) prompter.Prompter { +func newTestSurveyPrompter(t *testing.T, console *expect.Console) prompter.Prompter { t.Helper() t.Setenv("GH_ACCESSIBLE_PROMPTER", "false") @@ -429,7 +429,7 @@ func newTestSurveyPrompter(t testing.TB, console *expect.Console) prompter.Promp // assertion. // // Use WithRelaxedIO to disable this behaviour. -func failOnExpectError(t testing.TB) expect.ConsoleOpt { +func failOnExpectError(t *testing.T) expect.ConsoleOpt { t.Helper() return expect.WithExpectObserver( func(matchers []expect.Matcher, buf string, err error) { @@ -456,7 +456,7 @@ func failOnExpectError(t testing.TB) expect.ConsoleOpt { // if any sending of input fails, without requiring an explicit assertion. // // Use WithRelaxedIO to disable this behaviour. -func failOnSendError(t testing.TB) expect.ConsoleOpt { +func failOnSendError(t *testing.T) expect.ConsoleOpt { t.Helper() return expect.WithSendObserver( func(msg string, n int, err error) { @@ -473,7 +473,7 @@ func failOnSendError(t testing.TB) expect.ConsoleOpt { } // testCloser is a helper to fail the test if a Closer fails to close. -func testCloser(t testing.TB, closer io.Closer) { +func testCloser(t *testing.T, closer io.Closer) { t.Helper() if err := closer.Close(); err != nil { t.Errorf("Close failed: %s", err) From 8b70870f4f553ad364795e7523c1af00714b8ccf Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:39:59 -0600 Subject: [PATCH 43/48] test(prompter): describe why echo is editorcmd --- internal/prompter/accessible_prompter_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index e80c3085e..6d2bee92c 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -414,6 +414,9 @@ func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Pr t.Helper() t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") + // `echo`` is chose as the editor command because it immediately returns + // a success exit code, returns an empty string, doesn't require any user input, + // and since this file is only built on Linux, it is near guaranteed to be available. return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) } From 9eee77a2bf290558d65c6111b5c9ed53bd5648fe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:55:24 -0600 Subject: [PATCH 44/48] test(prompter): doc how accessible prompter tests work --- internal/prompter/accessible_prompter_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 6d2bee92c..60ae5162b 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -17,6 +17,19 @@ import ( "github.com/stretchr/testify/require" ) +// The following tests are broadly testing the accessible prompter, and NOT asserting +// on the prompter's complete and exact output strings. +// +// These tests generally operate with this logic: +// - Wait for a particular substring (a portion of the prompt) to appear +// - Send input +// - Wait for another substring to appear or for control to return to the test +// - Assert that the input value was returned from the prompter function + +// In the future, expanding these tests to assert on the exact prompt strings +// would help build confidence in `huh` upgrades, but for now these tests +// are sufficient to ensure that the accessible prompter behaves roughly as expected +// but doesn't mandate that prompts always look exactly the same. func TestAccessiblePrompter(t *testing.T) { t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) From 20ff409bfcf0d7316fd05ab3d06a970a434cdbd0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:56:12 -0600 Subject: [PATCH 45/48] fix(prompter): remove needless default value assignment --- internal/prompter/prompter.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 6cdbc9a87..7dd45bf5c 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -136,10 +136,6 @@ func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) ) err := form.Run() - - if result == "" { - return defaultValue, nil - } return result, err } From b8cd094ca8f7a3cf3ab77a1e69f94eaae6403626 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 10 Apr 2025 18:11:38 -0400 Subject: [PATCH 46/48] Ensure markdown confirm prompt shows editor name Apparently, `gh` might not actually have an editor at the time we're prompting the user if they want to use it for markdown editing. In the survey package, there is a function that will handle fallback to the default editor based on environment variables and parse it in the case the editor contains flags and arguments for cases like Visual Studio Code. Additionally, there are no tests for the EditorName function and the fact it is loaded via `init` makes this difficult to test. Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- internal/prompter/prompter.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 7dd45bf5c..cd53d7199 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -234,9 +234,9 @@ func (p *accessiblePrompter) InputHostname() (string, error) { func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string skipOption := "skip" - openOption := "open" + launchOption := "launch" options := []huh.Option[string]{ - huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), openOption), + huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption), } if blankAllowed { options = append(options, huh.NewOption("Skip", skipOption)) From 8cd39923fe273a059858b6e38f45cb4ba7d6a125 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:09:22 -0600 Subject: [PATCH 47/48] test(prompter): fix race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This test was trying to block on `expect`’ing a string at the same time the prompt was completed. This doesn't need to happen for this test. It should just check for the output from the Input prompt invocation. --- internal/prompter/accessible_prompter_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 60ae5162b..56096972d 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -108,10 +108,6 @@ func TestAccessiblePrompter(t *testing.T) { // Enter nothing _, err = console.SendLine("") require.NoError(t, err) - - // Expect the default value to be returned - _, err = console.ExpectString(dummyDefaultValue) - require.NoError(t, err) }() inputValue, err := p.Input("Enter some characters", dummyDefaultValue) From 70537de13260d714e5a0ef51c9525daa8fe1a217 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:18:56 -0600 Subject: [PATCH 48/48] test(prompter): fix invalid comment --- internal/prompter/prompter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index cd53d7199..6ef61cf15 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -259,7 +259,7 @@ func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAl return "", nil } - // openOption was selected + // launchOption was selected text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) if err != nil { return "", err