cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Check a new JEV API key with one test call and say how it went; golden screens for it and Save as replacing

Dami committed 19676c3
commit
19676c348767318a8bc381294a06ed37d7a39210
parent
d23e438e0b038b401edb6b08be69c19df3f31b00
author
Dami <dami.etoile@gmail.com>
12 files changed, +363 -27
Mdocs/architecture.md5++-
Mdocs/config.md3+-
Mdocs/jev.md9+++-
Me2e/config_test.go4+-
Me2e/screenlist_test.go24+++++++++-
Ae2e/testdata/screens/api-key-check-failed.html30+++++++++++
Ae2e/testdata/screens/save-as-replace.html30+++++++++++
Ainternal/jev/check.go62++++++++++++++++++++++++
Ainternal/jev/check_test.go78++++++++++++++++++++++++++++++
Ainternal/ui/keycheck_test.go79++++++++++++++++++++++++++++++
Minternal/ui/prefs.go64+++++++++++++++++-------
Minternal/ui/prefs_test.go2+-
M docs/architecture.md +4 -1
M docs/config.md +2 -1
M docs/jev.md +7 -2
M e2e/config_test.go +2 -2
M e2e/screenlist_test.go +23 -1
A e2e/testdata/screens/api-key-check-failed.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span>3+<span style="color:var(--vt-palette-3)">Key</span> <span style="color:var(--vt-palette-3)">saved,</span> <span style="color:var(--vt-palette-3)">but</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">check</span> <span style="color:var(--vt-palette-3)">failed:</span> <span style="color:var(--vt-palette-3)">couldn&#39;t</span> <span style="color:var(--vt-palette-3)">reach</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">service:</span> <span style="color:var(--vt-palette-3)">connection</span> <span style="color:var(--vt-palette-3)">refused</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/save-as-replace.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B7</span><span style="background:var(--vt-palette-8)"> </span> =SUM(B3:B5)3+<span style="color:var(--vt-palette-3)">budget.012</span> <span style="color:var(--vt-palette-3)">exists.</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> Replace <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Cancel4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 20266+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 14508+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.49+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit 9610+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A internal/jev/check.go +62 -0
@@ -0,0 +1,62 @@
1+package jev2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "net"8+ "strings"9+ "syscall"10+11+ typesafe "github.com/FelineStateMachine/typesafe-go"12+)13+14+// CheckQuestion is the question Check asks: fixed, trivial and about15+// nothing the user owns, so checking a key costs one small request and16+// sends no data.17+const CheckQuestion = "Is this arithmetic correct?"18+19+// checkState is the value CheckQuestion is asked about.20+const checkState = "2 + 2 = 4"21+22+// Check asks the service CheckQuestion with client, to see that its key23+// works. The error says why not, in words for the context line, and24+// never holds the key.25+func Check(ctx context.Context, client Client) error {26+ resp, err := client.SystemOne(ctx, typesafe.SystemOneRequest{27+ State: map[string]any{"value": checkState},28+ Questions: map[string]typesafe.Question{"answer": typesafe.NoulQuestion{Instructions: CheckQuestion}},29+ })30+ if err != nil {31+ return checkError(err)32+ }33+ if _, err := resp.Noul("answer"); err != nil {34+ return checkError(err)35+ }36+ return nil37+}38+39+// checkError words err for the context line.40+func checkError(err error) error {41+ var (42+ api *typesafe.APIError43+ errno syscall.Errno44+ dns *net.DNSError45+ op *net.OpError46+ )47+ switch {48+ case errors.As(err, &api) && (api.StatusCode == 401 || api.StatusCode == 403):49+ return fmt.Errorf("the service refused the key (HTTP %d)", api.StatusCode)50+ case errors.As(err, &api):51+ return fmt.Errorf("the service answered HTTP %d", api.StatusCode)52+ case errors.Is(err, context.DeadlineExceeded):53+ return errors.New("the service didn't answer in time")54+ case errors.As(err, &errno):55+ return fmt.Errorf("couldn't reach the service: %v", errno)56+ case errors.As(err, &dns):57+ return fmt.Errorf("couldn't reach the service: no such host %s", dns.Name)58+ case errors.As(err, &op):59+ return fmt.Errorf("couldn't reach the service: %v", op.Err)60+ }61+ return errors.New(strings.TrimPrefix(err.Error(), "typesafe: "))62+}
A internal/jev/check_test.go +78 -0
@@ -0,0 +1,78 @@
1+package jev2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "sync/atomic"10+ "testing"11+)12+13+// fakeTypeSafe stands in for the TypeSafe API, accepting only key and14+// answering yes/no questions with 0.9. It counts requests and remembers15+// the last question.16+func fakeTypeSafe(t *testing.T, key string) (*httptest.Server, *atomic.Int32, *atomic.Value) {17+ t.Helper()18+ var n atomic.Int3219+ var last atomic.Value20+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {21+ n.Add(1)22+ if r.Header.Get("Authorization") != "Bearer "+key {23+ w.Header().Set("Content-Type", "application/json")24+ w.WriteHeader(http.StatusUnauthorized)25+ w.Write([]byte(`{"error":{"message":"invalid api key"}}`))26+ return27+ }28+ var req struct {29+ Questions map[string]struct {30+ Type string `json:"type"`31+ Instructions string `json:"instructions"`32+ } `json:"questions"`33+ }34+ json.NewDecoder(r.Body).Decode(&req)35+ last.Store(req.Questions["answer"].Instructions)36+ json.NewEncoder(w).Encode(map[string]any{37+ "model": "jev-latest",38+ "answers": map[string]any{"answer": map[string]any{"type": "noul", "noul": 0.9}},39+ "usage": map[string]int{"input_tokens": 1, "output_tokens": 1},40+ })41+ }))42+ t.Cleanup(srv.Close)43+ return srv, &n, &last44+}45+46+// Check asks one fixed question; a key the service refuses fails with47+// a reason that doesn't hold the key.48+func TestCheck(t *testing.T) {49+ srv, n, last := fakeTypeSafe(t, "good-key")50+ c, err := NewClient(Config{APIKey: "good-key", BaseURL: srv.URL})51+ if err != nil {52+ t.Fatal(err)53+ }54+ if err := Check(context.Background(), c); err != nil {55+ t.Fatalf("good key: %v", err)56+ }57+ if n.Load() != 1 || last.Load() != CheckQuestion {58+ t.Errorf("%d requests, question %q", n.Load(), last.Load())59+ }60+61+ const bad = "bad-key-12345"62+ c, _ = NewClient(Config{APIKey: bad, BaseURL: srv.URL})63+ err = Check(context.Background(), c)64+ if err == nil || !strings.Contains(err.Error(), "refused the key (HTTP 401)") || strings.Contains(err.Error(), bad) {65+ t.Errorf("bad key: %v", err)66+ }67+}68+69+// A service that can't be reached is said in few words.70+func TestCheckUnreachable(t *testing.T) {71+ c, err := NewClient(Config{APIKey: "k", BaseURL: "http://127.0.0.1:1"})72+ if err != nil {73+ t.Fatal(err)74+ }75+ if err := Check(context.Background(), c); err == nil || err.Error() != "couldn't reach the service: connection refused" {76+ t.Errorf("unreachable: %v", err)77+ }78+}
A internal/ui/keycheck_test.go +79 -0
@@ -0,0 +1,79 @@
1+package ui2+3+import (4+ "encoding/json"5+ "net/http"6+ "net/http/httptest"7+ "os"8+ "path/filepath"9+ "strings"10+ "sync/atomic"11+ "testing"12+13+ tea "charm.land/bubbletea/v2"14+15+ "github.com/FelineStateMachine/012/internal/config"16+ "github.com/FelineStateMachine/012/internal/jev"17+ "github.com/FelineStateMachine/012/internal/keyring"18+)19+20+// fakeTypeSafe stands in for the TypeSafe API on this machine: it21+// accepts only goodKey and answers yes/no questions with 0.9.22+func fakeTypeSafe(t *testing.T, goodKey string) (*httptest.Server, *atomic.Int32) {23+ t.Helper()24+ var n atomic.Int3225+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {26+ n.Add(1)27+ if r.URL.Path != "/v1/systemone" || r.Header.Get("Authorization") != "Bearer "+goodKey {28+ w.Header().Set("Content-Type", "application/json")29+ w.WriteHeader(http.StatusUnauthorized)30+ w.Write([]byte(`{"error":{"message":"invalid api key"}}`))31+ return32+ }33+ json.NewEncoder(w).Encode(map[string]any{34+ "model": "jev-latest",35+ "answers": map[string]any{"answer": map[string]any{"type": "noul", "noul": 0.9}},36+ "usage": map[string]int{"input_tokens": 1, "output_tokens": 1},37+ })38+ }))39+ t.Cleanup(srv.Close)40+ return srv, &n41+}42+43+// Storing a key makes one test call with it and says how it went,44+// without ever showing the key; a refused key is still stored.45+func TestAPIKeyChecked(t *testing.T) {46+ const good, bad = "ts-good-4242", "ts-bad-1313"47+ srv, requests := fakeTypeSafe(t, good)48+ path := filepath.Join(t.TempDir(), "config")49+ os.WriteFile(path, nil, 0o600)50+ store := &keyring.Memory{}51+ m := newModel()52+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 20})53+ m.Configure(Settings{Config: config.Load(path, func(string) string { return "" }, nil), Keys: store,54+ Connect: func(key string) (jev.Client, error) {55+ return jev.NewClient(jev.Config{APIKey: key, BaseURL: srv.URL})56+ }})57+58+ m.runCommand("settings.jev_key")59+ press(t, m, good, "<enter>")60+ if got := line(m, contextLine); !strings.Contains(got, "Key saved and checked") || requests.Load() != 1 {61+ t.Fatalf("good key: %q after %d requests", got, requests.Load())62+ }63+64+ m.runCommand("settings.jev_key")65+ press(t, m, bad, "<enter>")66+ got := line(m, contextLine)67+ if !strings.Contains(got, "Key saved, but the check failed: the service refused the key (HTTP 401)") {68+ t.Errorf("bad key: %q", got)69+ }70+ if strings.Contains(screen(m), bad) || strings.Contains(screen(m), good) {71+ t.Error("a key is on the screen")72+ }73+ if k, _ := store.Get(t.Context()); k != bad || m.jev == nil {74+ t.Errorf("the refused key wasn't kept: %q, JEV on %v", k, m.jev != nil)75+ }76+ if n := requests.Load(); n != 2 {77+ t.Errorf("%d requests; want one per key", n)78+ }79+}
M internal/ui/prefs.go +45 -19
@@ -136,13 +136,19 @@ func (m *Model) reloadConfig() {
136136 m.note = "Config reloaded from " + config.Tilde(c.Path)137137 }138138 139-// keySavedMsg reports storing the API key, and the client made with it.139+// keySavedMsg reports storing the API key, the client made with it, and140+// how the test call with it went.140141 type keySavedMsg struct {141142 client jev.Client142- err error143+ err error // storing it or connecting failed144+ check string // why the test call failed, "" when it worked143145 }144146 145-// askKey asks for the API key on the context line, masked, and stores it.147+// keyCheckTimeout bounds the test call made with a new key.148+const keyCheckTimeout = 15 * time.Second149+150+// askKey asks for the API key on the context line, masked, stores it,151+// and checks it with one small test call (jev.Check).146152 func (m *Model) askKey() tea.Cmd {147153 m.openPrompt(&prompt{kind: promptText, label: "TypeSafe API key:", indicator: "KEY", secret: true,148154 onText: func(m *Model, key string) tea.Cmd {
@@ -150,25 +156,41 @@ func (m *Model) askKey() tea.Cmd {
150156 m.note = "No key entered; nothing changed"151157 return nil152158 }153- m.note = "Saving the key in the " + m.prefs.Keys.Name() + "…"154- store, connect := m.prefs.Keys, m.prefs.Connect155- return func() tea.Msg {156- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)157- defer cancel()158- if err := store.Set(ctx, key); err != nil {159- return keySavedMsg{err: err}160- }161- if connect == nil {162- return keySavedMsg{err: errors.New("JEV isn't available in this session")}163- }164- client, err := connect(key)165- return keySavedMsg{client: client, err: err}166- }159+ m.note = "Saving the key in the " + m.prefs.Keys.Name() + " and checking it…"160+ return saveKeyCmd(m.prefs.Keys, m.prefs.Connect, key)167161 }}, "")168162 return nil169163 }170164 171-// keySaved turns JEV on with the new key.165+// saveKeyCmd stores key, connects with it and makes the test call.166+func saveKeyCmd(store keyring.Store, connect func(string) (jev.Client, error), key string) tea.Cmd {167+ return func() tea.Msg {168+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)169+ defer cancel()170+ if err := store.Set(ctx, key); err != nil {171+ return keySavedMsg{err: err}172+ }173+ if connect == nil {174+ return keySavedMsg{err: errors.New("JEV isn't available in this session")}175+ }176+ client, err := connect(key)177+ if err != nil {178+ return keySavedMsg{err: err}179+ }180+ cctx, ccancel := context.WithTimeout(context.Background(), keyCheckTimeout)181+ defer ccancel()182+ msg := keySavedMsg{client: client}183+ if err := jev.Check(cctx, client); err != nil {184+ // Whatever the service said, it never gets to show the key.185+ msg.check = strings.ReplaceAll(err.Error(), key, "…")186+ }187+ return msg188+ }189+}190+191+// keySaved turns JEV on with the new key and says how its check went.192+// A key whose check failed is kept and used: the service may only be193+// unreachable for now.172194 func (m *Model) keySaved(msg keySavedMsg) {173195 if msg.err != nil {174196 m.fail("Couldn't save the API key: " + msg.err.Error())
@@ -180,7 +202,11 @@ func (m *Model) keySaved(msg keySavedMsg) {
180202 m.jev.client = msg.client181203 }182204 m.sheet.RecalcVolatile()183- m.note = fmt.Sprintf("API key saved in the %s; JEV functions are on", m.prefs.Keys.Name())205+ if msg.check != "" {206+ m.note = m.th.Warning.Render("Key saved, but the check failed: " + msg.check)207+ return208+ }209+ m.note = fmt.Sprintf("Key saved and checked; it's in the %s and JEV functions are on", m.prefs.Keys.Name())184210 }185211 186212 // handlePrefs handles the messages settings get: the terminal's
M internal/ui/prefs_test.go +1 -1