@pi-in-go/pigpen-pi-typesafe-api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +14 -0
- package/LICENSE +22 -0
- package/README.md +30 -0
- package/ask.go +62 -0
- package/ask_test.go +76 -0
- package/auth.go +249 -0
- package/auth_test.go +131 -0
- package/backends.go +336 -0
- package/backends_test.go +404 -0
- package/batch.go +202 -0
- package/batch_test.go +202 -0
- package/battery_test.go +41 -0
- package/calibrate.go +354 -0
- package/calibrate_test.go +186 -0
- package/client.go +615 -0
- package/client_test.go +490 -0
- package/credentials.go +252 -0
- package/credentials_test.go +216 -0
- package/doc.go +14 -0
- package/errors.go +143 -0
- package/evaluation.go +86 -0
- package/evaluation_schema.json +264 -0
- package/gaps_test.go +77 -0
- package/go.mod +9 -0
- package/go.sum +2 -0
- package/helpers_test.go +169 -0
- package/hostmodel/hostmodel.go +87 -0
- package/json.go +299 -0
- package/json_test.go +92 -0
- package/ownmodel_test.go +79 -0
- package/package.json +40 -0
- package/port/PORT.md +6 -0
- package/provenance.json +18 -0
- package/review_test.go +23 -0
- package/schema.go +473 -0
- package/schema_test.go +262 -0
- package/testdata/tools/typebox-messages.mts +5 -0
- package/testdata/typebox-messages.json +285 -0
- package/twin_test.go +28 -0
- package/ui/fakehost_test.go +548 -0
- package/ui/keyprompt.go +115 -0
- package/ui/login.go +106 -0
- package/ui/twin_test.go +28 -0
- package/ui/ui_test.go +285 -0
- package/usage.go +366 -0
- package/usage_test.go +139 -0
package/ui/login.go
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package ui
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
|
|
6
|
+
sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
|
|
7
|
+
pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// LoginResult is a completed login.
|
|
12
|
+
type LoginResult struct {
|
|
13
|
+
// Path is where the verified key was saved.
|
|
14
|
+
Path string
|
|
15
|
+
// Models is the number of models the key can access; it proves the API accepted the key.
|
|
16
|
+
Models int
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// LoginOptions configure the login helpers.
|
|
20
|
+
type LoginOptions struct {
|
|
21
|
+
// HTTPClient is a transport injection for offline tests.
|
|
22
|
+
HTTPClient typesafe.HTTPDoer
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// LoginWithPrompt asks for a TypeSafe key (hidden input), verifies it against api.typesafe.ai with a model
|
|
26
|
+
// listing, and stores it for every consumer of this package. It returns nil when the user cancels and a
|
|
27
|
+
// *pitypesafe.IntegrationError for an invalid or unverifiable key. It refuses when TYPESAFE_API_KEY is set,
|
|
28
|
+
// because the environment would shadow the stored key. Other backends have no login: their key is an
|
|
29
|
+
// environment variable only.
|
|
30
|
+
func LoginWithPrompt(ctx sdk.Context, opts LoginOptions) (*LoginResult, error) {
|
|
31
|
+
if pitypesafe.EnvKeySet() {
|
|
32
|
+
return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "TYPESAFE_API_KEY is set in the environment and takes precedence over a stored key. Unset it before logging in interactively."}
|
|
33
|
+
}
|
|
34
|
+
if !ctx.HasUI() {
|
|
35
|
+
return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "Logging in needs an interactive session. Set TYPESAFE_API_KEY in the environment instead."}
|
|
36
|
+
}
|
|
37
|
+
entered, ok, err := PromptForAPIKey(ctx)
|
|
38
|
+
if err != nil {
|
|
39
|
+
return nil, err
|
|
40
|
+
}
|
|
41
|
+
if !ok {
|
|
42
|
+
return nil, nil
|
|
43
|
+
}
|
|
44
|
+
key, err := pitypesafe.NormalizeAPIKey(entered)
|
|
45
|
+
if err != nil {
|
|
46
|
+
return nil, err
|
|
47
|
+
}
|
|
48
|
+
// Verify before saving so a bad paste fails here, not on first use.
|
|
49
|
+
client, err := pitypesafe.New(pitypesafe.Options{APIKey: key, HTTPClient: opts.HTTPClient})
|
|
50
|
+
if err != nil {
|
|
51
|
+
return nil, err
|
|
52
|
+
}
|
|
53
|
+
models, err := client.ListModels(context.Background())
|
|
54
|
+
if err != nil {
|
|
55
|
+
return nil, err
|
|
56
|
+
}
|
|
57
|
+
path, err := pitypesafe.StoreAPIKey(key)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return nil, err
|
|
60
|
+
}
|
|
61
|
+
return &LoginResult{Path: path, Models: len(models)}, nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// EnsureResult is the outcome of EnsureAPIKey: the source of the key in use, and the login when one ran.
|
|
65
|
+
type EnsureResult struct {
|
|
66
|
+
Source pitypesafe.KeySource
|
|
67
|
+
Login *LoginResult
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// EnsureAPIKey uses the configured key for the backend if there is one; otherwise it runs the login prompt. A
|
|
71
|
+
// nil result means the user cancelled. A store that must not be read returns a configuration error with the
|
|
72
|
+
// reason instead of prompting, so a permissions problem stays visible. A backend without a login store (every
|
|
73
|
+
// backend except TypeSafe) returns a configuration error naming its environment variable instead of
|
|
74
|
+
// prompting, because the prompt would verify the key against the wrong service and store it where the
|
|
75
|
+
// TypeSafe key lives. backend nil means the default.
|
|
76
|
+
func EnsureAPIKey(ctx sdk.Context, backend any, opts LoginOptions) (*EnsureResult, error) {
|
|
77
|
+
if backend == nil {
|
|
78
|
+
backend = pitypesafe.DefaultBackend
|
|
79
|
+
}
|
|
80
|
+
situation, err := pitypesafe.KeySituationFor(backend)
|
|
81
|
+
if err != nil {
|
|
82
|
+
return nil, err
|
|
83
|
+
}
|
|
84
|
+
switch situation.Kind {
|
|
85
|
+
case pitypesafe.KeyEnvironment:
|
|
86
|
+
return &EnsureResult{Source: pitypesafe.SourceEnvironment}, nil
|
|
87
|
+
case pitypesafe.KeyStored:
|
|
88
|
+
return &EnsureResult{Source: pitypesafe.SourceStored}, nil
|
|
89
|
+
case pitypesafe.KeyNotRequired:
|
|
90
|
+
return &EnsureResult{}, nil
|
|
91
|
+
case pitypesafe.KeyUnusable:
|
|
92
|
+
return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: situation.Reason}
|
|
93
|
+
}
|
|
94
|
+
config, err := pitypesafe.ResolveBackend(backend)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return nil, err
|
|
97
|
+
}
|
|
98
|
+
if !pitypesafe.UsesTypeSafeKey(config.BackendConfig) {
|
|
99
|
+
return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "No " + config.Label + " key. Set " + config.KeyEnv + " in the environment; /typesafe login stores a TypeSafe key only."}
|
|
100
|
+
}
|
|
101
|
+
login, err := LoginWithPrompt(ctx, opts)
|
|
102
|
+
if err != nil || login == nil {
|
|
103
|
+
return nil, err
|
|
104
|
+
}
|
|
105
|
+
return &EnsureResult{Source: pitypesafe.SourceStored, Login: login}, nil
|
|
106
|
+
}
|
package/ui/twin_test.go
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package ui_test
|
|
2
|
+
|
|
3
|
+
// Twin helpers for a port whose original has tests. Every upstream test case gets one
|
|
4
|
+
// tw(...) call carrying the upstream title unchanged, or one tskip(...) with the reason it
|
|
5
|
+
// cannot be ported. `pigeq twins check` reads these calls and fails on a missing title, an
|
|
6
|
+
// unknown one (a misspelling) or one reason shared by many skips.
|
|
7
|
+
//
|
|
8
|
+
// pigeq twins list --tests <upstream>/test > port/upstream-tests.json
|
|
9
|
+
// pigeq twins check --ledger port/upstream-tests.json --go extensions/<name> [--files a,b]
|
|
10
|
+
//
|
|
11
|
+
// Copy this file into the port as twin_test.go and replace ui_test with the package name (in
|
|
12
|
+
// an external test package use `ui_test_test`). A parametrized upstream title (it contains
|
|
13
|
+
// `${`) is twinned once, with the template text unchanged.
|
|
14
|
+
|
|
15
|
+
import "testing"
|
|
16
|
+
|
|
17
|
+
// tw runs one exact twin: file is the upstream test file (its name without .test.mjs), title
|
|
18
|
+
// the upstream test title, unchanged.
|
|
19
|
+
func tw(t *testing.T, file, title string, fn func(t *testing.T)) {
|
|
20
|
+
t.Helper()
|
|
21
|
+
t.Run(title, fn)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// tskip records a named gap: the upstream case has no Go twin, with its own reason.
|
|
25
|
+
func tskip(t *testing.T, file, title, reason string) {
|
|
26
|
+
t.Helper()
|
|
27
|
+
t.Run(title, func(t *testing.T) { t.Skip(reason) })
|
|
28
|
+
}
|
package/ui/ui_test.go
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
package ui_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"io"
|
|
7
|
+
"net/http"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"regexp"
|
|
11
|
+
"runtime"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync/atomic"
|
|
14
|
+
"testing"
|
|
15
|
+
"unicode/utf8"
|
|
16
|
+
|
|
17
|
+
sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
|
|
18
|
+
pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
|
|
19
|
+
"github.com/MichaelKinsy/pigpen/components/pi-typesafe-api/ui"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
func isolate(t *testing.T) string {
|
|
23
|
+
t.Helper()
|
|
24
|
+
dir := t.TempDir()
|
|
25
|
+
agent := filepath.Join(dir, "agent")
|
|
26
|
+
t.Setenv("HOME", dir)
|
|
27
|
+
t.Setenv("PIG_HOME", filepath.Join(dir, "pighome"))
|
|
28
|
+
t.Setenv("PIG_CODING_AGENT_DIR", agent)
|
|
29
|
+
t.Setenv("PIG_USE_PI_DIRS", "")
|
|
30
|
+
for _, name := range []string{"TYPESAFE_API_KEY", "OPENROUTER_API_KEY"} {
|
|
31
|
+
t.Setenv(name, "")
|
|
32
|
+
}
|
|
33
|
+
return agent
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type doerFunc func(*http.Request) (*http.Response, error)
|
|
37
|
+
|
|
38
|
+
func (f doerFunc) Do(r *http.Request) (*http.Response, error) { return f(r) }
|
|
39
|
+
|
|
40
|
+
// loginRig runs a test extension whose /login command calls the helpers under test.
|
|
41
|
+
type loginRig struct {
|
|
42
|
+
t *testing.T
|
|
43
|
+
host *Host
|
|
44
|
+
custom any // nil: cancelled; string: typed key; "unsupported": no custom components
|
|
45
|
+
inputText string
|
|
46
|
+
modelCalls atomic.Int32
|
|
47
|
+
result *ui.LoginResult
|
|
48
|
+
ensured *ui.EnsureResult
|
|
49
|
+
err error
|
|
50
|
+
backend any
|
|
51
|
+
ensure bool
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func newLoginRig(t *testing.T, hasUI bool) *loginRig {
|
|
55
|
+
t.Helper()
|
|
56
|
+
r := &loginRig{t: t}
|
|
57
|
+
opts := ui.LoginOptions{HTTPClient: doerFunc(func(req *http.Request) (*http.Response, error) {
|
|
58
|
+
if !strings.HasSuffix(req.URL.Path, "/v1/models") {
|
|
59
|
+
t.Errorf("unexpected request to %s", req.URL)
|
|
60
|
+
return nil, io.ErrUnexpectedEOF
|
|
61
|
+
}
|
|
62
|
+
r.modelCalls.Add(1)
|
|
63
|
+
body, _ := json.Marshal(map[string]any{"models": []any{map[string]any{"name": "jev-latest", "description": "", "release_date": "2026-01-01"}, map[string]any{"name": "jev-preview", "description": "", "release_date": "2026-01-01"}}})
|
|
64
|
+
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil
|
|
65
|
+
})}
|
|
66
|
+
ext := sdk.New("ui-test")
|
|
67
|
+
ext.Command("run", "runs the helper under test", func(ctx sdk.Context, _ string) error {
|
|
68
|
+
if r.ensure {
|
|
69
|
+
r.ensured, r.err = ui.EnsureAPIKey(ctx, r.backend, opts)
|
|
70
|
+
} else {
|
|
71
|
+
r.result, r.err = ui.LoginWithPrompt(ctx, opts)
|
|
72
|
+
}
|
|
73
|
+
return nil
|
|
74
|
+
})
|
|
75
|
+
r.host = StartHost(t, ext, HostOptions{HasUI: &hasUI, OnCall: func(method string, args map[string]any) (map[string]any, string) {
|
|
76
|
+
switch method {
|
|
77
|
+
case "ui.custom":
|
|
78
|
+
switch v := r.custom.(type) {
|
|
79
|
+
case nil:
|
|
80
|
+
return map[string]any{"ok": false}, ""
|
|
81
|
+
case string:
|
|
82
|
+
if v == "unsupported" {
|
|
83
|
+
return nil, "custom components are unavailable"
|
|
84
|
+
}
|
|
85
|
+
return map[string]any{"ok": true, "result": v}, ""
|
|
86
|
+
}
|
|
87
|
+
case "ui.input":
|
|
88
|
+
if r.custom != "unsupported" {
|
|
89
|
+
return nil, "plain input must not be used when custom UI exists"
|
|
90
|
+
}
|
|
91
|
+
return map[string]any{"text": r.inputText, "ok": true}, ""
|
|
92
|
+
}
|
|
93
|
+
return nil, ""
|
|
94
|
+
}})
|
|
95
|
+
return r
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func (r *loginRig) run() {
|
|
99
|
+
r.t.Helper()
|
|
100
|
+
r.result, r.ensured, r.err = nil, nil, nil
|
|
101
|
+
if failure := r.host.Command("run", ""); failure != "" {
|
|
102
|
+
r.t.Fatalf("command failed: %s", failure)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func isCode(err error, code pitypesafe.ErrorCode) bool {
|
|
107
|
+
ie, ok := err.(*pitypesafe.IntegrationError)
|
|
108
|
+
return ok && ie.Code == code
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func TestLogin(t *testing.T) {
|
|
112
|
+
tw(t, "login", "loginWithPrompt cancels cleanly, rejects bad keys, and stores a verified key with owner-only permissions", func(t *testing.T) {
|
|
113
|
+
agent := isolate(t)
|
|
114
|
+
stored := filepath.Join(agent, "pi-typesafe", "auth.json")
|
|
115
|
+
r := newLoginRig(t, true)
|
|
116
|
+
r.run()
|
|
117
|
+
if r.result != nil || r.err != nil {
|
|
118
|
+
t.Fatalf("cancel: %v %v", r.result, r.err)
|
|
119
|
+
}
|
|
120
|
+
if _, err := os.Stat(stored); err == nil {
|
|
121
|
+
t.Fatal("nothing may be saved on cancel")
|
|
122
|
+
}
|
|
123
|
+
r.custom = "nope"
|
|
124
|
+
r.run()
|
|
125
|
+
if !isCode(r.err, pitypesafe.CodeValidation) || strings.Contains(r.err.Error(), "nope") || r.modelCalls.Load() != 0 {
|
|
126
|
+
t.Fatalf("bad key: %v", r.err)
|
|
127
|
+
}
|
|
128
|
+
r.custom = "ts_live_key_0123456789abcdef"
|
|
129
|
+
r.run()
|
|
130
|
+
if r.err != nil || r.result == nil || *r.result != (ui.LoginResult{Path: stored, Models: 2}) || r.modelCalls.Load() != 1 {
|
|
131
|
+
t.Fatalf("login: %+v %v", r.result, r.err)
|
|
132
|
+
}
|
|
133
|
+
if info, _ := os.Stat(stored); runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
|
134
|
+
t.Errorf("mode = %v", info.Mode().Perm())
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
tw(t, "login", "loginWithPrompt refuses when the environment key would shadow the store, or without a UI", func(t *testing.T) {
|
|
138
|
+
agent := isolate(t)
|
|
139
|
+
r := newLoginRig(t, true)
|
|
140
|
+
t.Setenv("TYPESAFE_API_KEY", "env-key-0123456789")
|
|
141
|
+
r.custom = "ts_live_key_0123456789abcdef"
|
|
142
|
+
r.run()
|
|
143
|
+
if !isCode(r.err, pitypesafe.CodeConfiguration) || !strings.Contains(r.err.Error(), "TYPESAFE_API_KEY") {
|
|
144
|
+
t.Fatalf("env: %v", r.err)
|
|
145
|
+
}
|
|
146
|
+
t.Setenv("TYPESAFE_API_KEY", "")
|
|
147
|
+
headless := newLoginRig(t, false)
|
|
148
|
+
headless.run()
|
|
149
|
+
if headless.err == nil || !regexp.MustCompile(`interactive`).MatchString(headless.err.Error()) {
|
|
150
|
+
t.Fatalf("headless: %v", headless.err)
|
|
151
|
+
}
|
|
152
|
+
if _, err := os.Stat(filepath.Join(agent, "pi-typesafe", "auth.json")); err == nil {
|
|
153
|
+
t.Fatal("nothing may be saved")
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
tw(t, "login", "ensureApiKey reports an existing key without prompting, otherwise logs in", func(t *testing.T) {
|
|
157
|
+
agent := isolate(t)
|
|
158
|
+
stored := filepath.Join(agent, "pi-typesafe", "auth.json")
|
|
159
|
+
r := newLoginRig(t, true)
|
|
160
|
+
r.ensure = true
|
|
161
|
+
t.Setenv("TYPESAFE_API_KEY", "env-key-0123456789")
|
|
162
|
+
r.custom = "must-not-be-used-0123456789"
|
|
163
|
+
r.run()
|
|
164
|
+
if r.err != nil || r.ensured == nil || r.ensured.Source != pitypesafe.SourceEnvironment || r.ensured.Login != nil {
|
|
165
|
+
t.Fatalf("env: %+v %v", r.ensured, r.err)
|
|
166
|
+
}
|
|
167
|
+
if _, err := os.Stat(stored); err == nil {
|
|
168
|
+
t.Fatal("an existing key must not prompt or store")
|
|
169
|
+
}
|
|
170
|
+
t.Setenv("TYPESAFE_API_KEY", "")
|
|
171
|
+
r.custom = nil
|
|
172
|
+
r.run()
|
|
173
|
+
if r.ensured != nil || r.err != nil {
|
|
174
|
+
t.Fatalf("cancelled prompt: %+v %v", r.ensured, r.err)
|
|
175
|
+
}
|
|
176
|
+
r.custom = "ts_live_key_0123456789abcdef"
|
|
177
|
+
r.run()
|
|
178
|
+
want := ui.EnsureResult{Source: pitypesafe.SourceStored, Login: &ui.LoginResult{Path: stored, Models: 2}}
|
|
179
|
+
if r.err != nil || r.ensured == nil || r.ensured.Source != want.Source || *r.ensured.Login != *want.Login {
|
|
180
|
+
t.Fatalf("login: %+v %v", r.ensured, r.err)
|
|
181
|
+
}
|
|
182
|
+
r.custom = "another-key-that-must-not-replace-it"
|
|
183
|
+
r.run()
|
|
184
|
+
if r.err != nil || r.ensured == nil || r.ensured.Source != pitypesafe.SourceStored || r.ensured.Login != nil || r.modelCalls.Load() != 1 {
|
|
185
|
+
t.Fatalf("second call reuses the stored key: %+v %v", r.ensured, r.err)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
tw(t, "login", "ensureApiKey for another backend uses its environment variable and never opens the TypeSafe login", func(t *testing.T) {
|
|
189
|
+
agent := isolate(t)
|
|
190
|
+
r := newLoginRig(t, true)
|
|
191
|
+
r.ensure = true
|
|
192
|
+
// A stored TypeSafe key does not satisfy OpenRouter, and the prompt must not run: it would verify against api.typesafe.ai.
|
|
193
|
+
r.custom = "ts_live_key_0123456789abcdef"
|
|
194
|
+
r.run()
|
|
195
|
+
if r.err != nil || r.ensured == nil || r.ensured.Login == nil || r.ensured.Login.Path != filepath.Join(agent, "pi-typesafe", "auth.json") {
|
|
196
|
+
t.Fatalf("typesafe login: %+v %v", r.ensured, r.err)
|
|
197
|
+
}
|
|
198
|
+
r.backend = "openrouter"
|
|
199
|
+
r.run()
|
|
200
|
+
if !isCode(r.err, pitypesafe.CodeConfiguration) || !strings.Contains(r.err.Error(), "OPENROUTER_API_KEY") || r.modelCalls.Load() != 1 {
|
|
201
|
+
t.Fatalf("openrouter: %v models=%d", r.err, r.modelCalls.Load())
|
|
202
|
+
}
|
|
203
|
+
t.Setenv("OPENROUTER_API_KEY", "sk-or-0123456789abcdef")
|
|
204
|
+
r.run()
|
|
205
|
+
if r.err != nil || r.ensured == nil || r.ensured.Source != pitypesafe.SourceEnvironment || r.modelCalls.Load() != 1 {
|
|
206
|
+
t.Fatalf("openrouter env: %+v %v", r.ensured, r.err)
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func stripANSI(s string) string { return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(s, "") }
|
|
212
|
+
|
|
213
|
+
func TestKeyPrompt(t *testing.T) {
|
|
214
|
+
tw(t, "key-prompt", "typed key is masked on screen and returned on Enter", func(t *testing.T) {
|
|
215
|
+
p := ui.NewKeyPrompt()
|
|
216
|
+
for _, c := range "ts_secret_key_0123456789" {
|
|
217
|
+
if res, err := p.HandleInput(string(c)); err != nil || res.Done {
|
|
218
|
+
t.Fatalf("typing ended early: %+v %v", res, err)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
frame := p.Render(60)
|
|
222
|
+
res, err := p.HandleInput("\r")
|
|
223
|
+
if err != nil || !res.Done || res.Value != "ts_secret_key_0123456789" {
|
|
224
|
+
t.Fatalf("enter: %+v %v", res, err)
|
|
225
|
+
}
|
|
226
|
+
screen := strings.Join(frame, "\n")
|
|
227
|
+
if strings.Contains(screen, "ts_secret") || !strings.Contains(screen, strings.Repeat("•", 24)) || !strings.Contains(screen, "TypeSafe API key") {
|
|
228
|
+
t.Fatalf("screen = %q", screen)
|
|
229
|
+
}
|
|
230
|
+
for _, line := range frame {
|
|
231
|
+
if utf8.RuneCountInString(stripANSI(line)) > 60+8 {
|
|
232
|
+
t.Errorf("line too wide: %q", line)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
tw(t, "key-prompt", "Escape cancels without a value", func(t *testing.T) {
|
|
237
|
+
p := ui.NewKeyPrompt()
|
|
238
|
+
_, _ = p.HandleInput("a")
|
|
239
|
+
res, err := p.HandleInput("\x1b")
|
|
240
|
+
if err != nil || !res.Done || res.Value != nil {
|
|
241
|
+
t.Fatalf("escape: %+v %v", res, err)
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
tw(t, "key-prompt", "falls back to Pi's plain input when custom UI is unavailable", func(t *testing.T) {
|
|
245
|
+
isolate(t)
|
|
246
|
+
r := newLoginRig(t, true)
|
|
247
|
+
r.custom = "unsupported"
|
|
248
|
+
r.inputText = " ts_plain_key_0123456789 "
|
|
249
|
+
var got string
|
|
250
|
+
var ok bool
|
|
251
|
+
ext := sdk.New("prompt-test")
|
|
252
|
+
ext.Command("prompt", "", func(ctx sdk.Context, _ string) error {
|
|
253
|
+
var err error
|
|
254
|
+
got, ok, err = ui.PromptForAPIKey(ctx)
|
|
255
|
+
return err
|
|
256
|
+
})
|
|
257
|
+
host := StartHost(t, ext, HostOptions{OnCall: func(method string, _ map[string]any) (map[string]any, string) {
|
|
258
|
+
switch method {
|
|
259
|
+
case "ui.custom":
|
|
260
|
+
return nil, "custom components are unavailable"
|
|
261
|
+
case "ui.input":
|
|
262
|
+
return map[string]any{"text": r.inputText, "ok": true}, ""
|
|
263
|
+
}
|
|
264
|
+
return nil, ""
|
|
265
|
+
}})
|
|
266
|
+
if failure := host.Command("prompt", ""); failure != "" || !ok || got != r.inputText {
|
|
267
|
+
t.Fatalf("fallback: %q %v %q", got, ok, failure)
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func TestKeyPromptEditing(t *testing.T) {
|
|
273
|
+
p := ui.NewKeyPrompt()
|
|
274
|
+
_, _ = p.HandleInput("\x1b[200~ts_pasted_0123456789\x1b[201~")
|
|
275
|
+
_, _ = p.HandleInput("X")
|
|
276
|
+
_, _ = p.HandleInput("\x7f")
|
|
277
|
+
_, _ = p.HandleInput("\x1b[A") // an arrow key is ignored
|
|
278
|
+
res, _ := p.HandleInput("\r")
|
|
279
|
+
if res.Value != "ts_pasted_0123456789" {
|
|
280
|
+
t.Fatalf("value = %q", res.Value)
|
|
281
|
+
}
|
|
282
|
+
if got := ui.NewKeyPrompt().Render(4); len(got) < 3 {
|
|
283
|
+
t.Fatalf("a narrow render still draws every line: %v", got)
|
|
284
|
+
}
|
|
285
|
+
}
|