Merge pull request #10710 from cli/kw/first-pass-accessible-prompter

Introduce accessible prompter for screen readers (preview)
This commit is contained in:
Kynan Ware 2025-04-10 17:28:26 -06:00 committed by GitHub
commit dec71dd9e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 775 additions and 8 deletions

14
go.mod
View file

@ -7,9 +7,11 @@ 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/glamour v0.9.2-0.20250319212134-549f544650e3
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
@ -29,6 +31,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
@ -64,12 +67,17 @@ 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.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
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 +90,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.8.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
@ -117,12 +127,16 @@ require (
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // 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
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

29
go.sum
View file

@ -52,6 +52,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.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
@ -94,22 +96,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.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.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=
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.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=
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-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=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
@ -158,6 +170,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=
@ -335,6 +351,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=
@ -351,10 +369,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=
@ -542,6 +566,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=

View file

@ -0,0 +1,493 @@
//go:build !windows
package prompter_test
import (
"fmt"
"io"
"strings"
"testing"
"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"
"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)
p := newTestAcessiblePrompter(t, console)
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) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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("Input - blank input returns default value", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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)
}()
inputValue, err := p.Input("Enter some characters", dummyDefaultValue)
require.NoError(t, err)
assert.Equal(t, dummyDefaultValue, inputValue)
})
t.Run("Password", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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("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")
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) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
dummyAuthToken := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Paste your authentication token:")
require.NoError(t, err)
// Enter some dummy auth token
_, err = console.SendLine(dummyAuthToken)
require.NoError(t, err)
}()
authValue, err := p.AuthToken()
require.NoError(t, err)
require.Equal(t, dummyAuthToken, authValue)
})
t.Run("AuthToken - blank input returns error", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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("ConfirmDeletion - bad input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
hostname := "example.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 with blank input returns blank", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
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 with default value returns default value", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
defaultValue := "12345abcdefg"
go func() {
// 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. 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)
})
t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
go func() {
// 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)
}()
inputValue, err := p.MarkdownEditor("How to edit?", "", false)
require.NoError(t, err)
require.Equal(t, "", inputValue)
})
}
func TestSurveyPrompter(t *testing.T) {
// 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) {
console := newTestVirtualTerminal(t)
p := newTestSurveyPrompter(t, console)
go func() {
// 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 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("")
require.NoError(t, err)
}()
selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"})
require.NoError(t, err)
assert.Equal(t, 0, selectValue)
})
}
func newTestVirtualTerminal(t *testing.T) *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.T, console *expect.Console) prompter.Prompter {
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())
}
func newTestSurveyPrompter(t *testing.T, 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.
//
// Use WithRelaxedIO to disable this behaviour.
func failOnExpectError(t *testing.T) 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.T) 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.T, closer io.Closer) {
t.Helper()
if err := closer.Close(); err != nil {
t.Errorf("Close failed: %s", err)
}
}

View file

@ -2,9 +2,12 @@ package prompter
import (
"fmt"
"os"
"slices"
"strings"
"github.com/AlecAivazis/survey/v2"
"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"
@ -13,20 +16,46 @@ 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 {
accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER")
falseyValues := []string{"false", "0", "no", ""}
if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) {
return &accessiblePrompter{
stdin: stdin,
stdout: stdout,
stderr: stderr,
editorCmd: editorCmd,
}
}
return &surveyPrompter{
prompter: ghPrompter.New(stdin, stdout, stderr),
stdin: stdin,
@ -36,6 +65,209 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr
}
}
type accessiblePrompter struct {
stdin ghPrompter.FileReader
stdout ghPrompter.FileWriter
stderr ghPrompter.FileWriter
editorCmd string
}
func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form {
return huh.NewForm(groups...).
WithTheme(huh.ThemeBase16()).
WithAccessible(true).
WithInput(p.stdin).
WithOutput(p.stdout)
}
func (p *accessiblePrompter) 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 *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 {
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
}
return result, nil
}
func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) {
result := defaultValue
prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue)
form := p.newForm(
huh.NewGroup(
huh.NewInput().
Title(prompt).
Value(&result),
),
)
err := form.Run()
return result, err
}
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(
huh.NewGroup(
huh.NewInput().
Title(prompt).
Value(&result),
),
)
err := form.Run()
if err != nil {
return "", err
}
return result, nil
}
func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) {
result := defaultValue
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 *accessiblePrompter) AuthToken() (string, error) {
var result string
form := p.newForm(
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")
}
return nil
}).
Value(&result),
// This doesn't have any effect in accessible mode.
// EchoMode(huh.EchoModePassword),
),
)
err := form.Run()
return result, err
}
func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error {
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
}),
),
)
return form.Run()
}
func (p *accessiblePrompter) InputHostname() (string, error) {
var result string
form := p.newForm(
huh.NewGroup(
huh.NewInput().
Title("Hostname:").
Validate(ghinstance.HostnameValidator).
Value(&result),
),
)
err := form.Run()
if err != nil {
return "", err
}
return result, nil
}
func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) {
var result string
skipOption := "skip"
launchOption := "launch"
options := []huh.Option[string]{
huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption),
}
if blankAllowed {
options = append(options, huh.NewOption("Skip", skipOption))
}
form := p.newForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(prompt).
Options(options...).
Value(&result),
),
)
if err := form.Run(); err != nil {
return "", err
}
if result == skipOption {
return "", nil
}
// launchOption was selected
text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr)
if err != nil {
return "", err
}
return text, nil
}
type surveyPrompter struct {
prompter *ghPrompter.Prompter
stdin ghPrompter.FileReader

View file

@ -111,6 +111,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_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are
more compatible with speech synthesis and braille screen readers.
`, "`"),
},
{