Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f26ea5ad6 | ||
|
|
3b36f07cb8 | ||
|
|
acd29c8221 | ||
|
|
72042516e2 |
@@ -0,0 +1,534 @@
|
||||
// Package anthropic is the llm.Provider adapter for Anthropic's Messages
|
||||
// API. It calls POST {base}/v1/messages and GET {base}/v1/models over plain
|
||||
// net/http.
|
||||
//
|
||||
// The adapter never logs, and no error it returns contains the API key:
|
||||
// errors do not quote caller- or vendor-supplied text, and every exported
|
||||
// method passes its error through redact as a backstop.
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// APIVersion is the Messages API version sent as the anthropic-version
|
||||
// header.
|
||||
const APIVersion = "2023-06-01"
|
||||
|
||||
// Config configures a Provider.
|
||||
type Config struct {
|
||||
// BaseURL is the API root the /v1 endpoint paths are appended to, e.g.
|
||||
// "https://api.anthropic.com".
|
||||
BaseURL string
|
||||
// APIKey is sent as the x-api-key header. It must not be empty.
|
||||
APIKey string
|
||||
// Model is the model Generate requests. ListModels and Verify do not
|
||||
// need it.
|
||||
Model string
|
||||
// MaxTokens is the max_tokens Generate sends when a Request sets none:
|
||||
// the Messages API requires one. It must be positive.
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// Provider is an Anthropic Messages API llm.Provider.
|
||||
type Provider struct {
|
||||
base string
|
||||
apiKey string
|
||||
model string
|
||||
maxTokens int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ llm.Provider = (*Provider)(nil)
|
||||
|
||||
// New validates cfg and returns a Provider.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
p, err := newFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, redact(err, cfg.APIKey)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func newFromConfig(cfg Config) (*Provider, error) {
|
||||
// No error from New quotes caller-supplied text: any of it may equal or
|
||||
// contain the API key. Base URL errors never echo the URL.
|
||||
u, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("anthropic: base URL does not parse")
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("anthropic: base URL must be an absolute http or https URL")
|
||||
}
|
||||
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||
// endpoint path appended to it would land in the query or fragment.
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("anthropic: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, errors.New("anthropic: no API key configured")
|
||||
}
|
||||
if !validHeaderValue(cfg.APIKey) {
|
||||
return nil, errors.New("anthropic: API key has a control character")
|
||||
}
|
||||
if cfg.MaxTokens <= 0 {
|
||||
return nil, errors.New("anthropic: max tokens must be positive")
|
||||
}
|
||||
return &Provider{
|
||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
maxTokens: cfg.MaxTokens,
|
||||
client: &http.Client{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// toolName is the pattern the Messages API requires of a tool name.
|
||||
var toolName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||
const maxResponseBody = 16 << 20
|
||||
|
||||
// maxErrorBody caps how much of an error response body is read. It is far
|
||||
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
|
||||
// reach the excerpt.
|
||||
const maxErrorBody = 64 << 10
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type tool struct {
|
||||
Name string `json:"name"`
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
}
|
||||
|
||||
type toolChoice struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type messagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []message `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Tools []tool `json:"tools,omitempty"`
|
||||
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
|
||||
}
|
||||
|
||||
type messagesResponse struct {
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// Generate sends req to {base}/v1/messages. A Request with a Schema is sent
|
||||
// as one tool whose input_schema is the schema, with tool_choice forcing
|
||||
// it; Response.JSON is then that tool's input.
|
||||
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
resp, err := p.generate(ctx, req)
|
||||
return resp, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||
}
|
||||
if p.model == "" {
|
||||
return llm.Response{}, errors.New("anthropic: generate: no model configured")
|
||||
}
|
||||
body := messagesRequest{
|
||||
Model: p.model,
|
||||
MaxTokens: p.maxTokens,
|
||||
System: req.System,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
body.MaxTokens = req.MaxTokens
|
||||
}
|
||||
if req.Schema != nil {
|
||||
if !toolName.MatchString(req.Schema.Name) {
|
||||
return llm.Response{}, errors.New("anthropic: generate: schema name is not a valid tool name")
|
||||
}
|
||||
body.Tools = []tool{{Name: req.Schema.Name, InputSchema: req.Schema.Schema}}
|
||||
body.ToolChoice = &toolChoice{Type: "tool", Name: req.Schema.Name}
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: encode request: %w", err)
|
||||
}
|
||||
|
||||
if req.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodPost, "/v1/messages", payload)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||
}
|
||||
|
||||
var mr messagesResponse
|
||||
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: decode response: %w", err)
|
||||
}
|
||||
if mr.Type != "message" {
|
||||
return llm.Response{}, errors.New("anthropic: generate: response is not a message")
|
||||
}
|
||||
resp := llm.Response{
|
||||
Usage: llm.Usage{InputTokens: mr.Usage.InputTokens, OutputTokens: mr.Usage.OutputTokens},
|
||||
Model: mr.Model,
|
||||
FinishReason: mr.StopReason,
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, block := range mr.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
text.WriteString(block.Text)
|
||||
case "tool_use":
|
||||
if req.Schema != nil && block.Name == req.Schema.Name && resp.JSON == nil {
|
||||
resp.JSON = block.Input
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Text = text.String()
|
||||
if req.Schema == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if !isJSONObject(resp.JSON) {
|
||||
resp.JSON = nil
|
||||
return resp, fmt.Errorf("anthropic: generate: reply holds no input for the forced tool: %w", llm.ErrBadOutput)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// isJSONObject reports whether raw is exactly one JSON object.
|
||||
func isJSONObject(raw json.RawMessage) bool {
|
||||
s := bytes.TrimSpace(raw)
|
||||
return bytes.HasPrefix(s, []byte("{")) && json.Valid(s)
|
||||
}
|
||||
|
||||
type modelsResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"data"`
|
||||
HasMore bool `json:"has_more"`
|
||||
LastID string `json:"last_id"`
|
||||
}
|
||||
|
||||
// modelsPageLimit is the largest page GET /v1/models serves.
|
||||
const modelsPageLimit = 1000
|
||||
|
||||
// ListModels returns every model {base}/v1/models lists, following pages.
|
||||
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models, err := p.listModels(ctx)
|
||||
return models, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models := []llm.Model{}
|
||||
after := ""
|
||||
for {
|
||||
page, err := p.modelsPage(ctx, modelsPageLimit, after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anthropic: list models: %w", err)
|
||||
}
|
||||
for _, m := range page.Data {
|
||||
if m.ID == "" {
|
||||
return nil, fmt.Errorf("anthropic: list models: model %d has no id", len(models))
|
||||
}
|
||||
name := m.DisplayName
|
||||
if name == "" {
|
||||
name = m.ID
|
||||
}
|
||||
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||
}
|
||||
if !page.HasMore {
|
||||
return models, nil
|
||||
}
|
||||
if page.LastID == "" || page.LastID == after {
|
||||
return nil, errors.New("anthropic: list models: response has more pages but no new cursor")
|
||||
}
|
||||
after = page.LastID
|
||||
}
|
||||
}
|
||||
|
||||
// modelsPage fetches one page of GET {base}/v1/models.
|
||||
func (p *Provider) modelsPage(ctx context.Context, limit int, after string) (modelsResponse, error) {
|
||||
q := url.Values{"limit": {strconv.Itoa(limit)}}
|
||||
if after != "" {
|
||||
q.Set("after_id", after)
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return modelsResponse{}, err
|
||||
}
|
||||
var mr modelsResponse
|
||||
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||
return modelsResponse{}, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if mr.Data == nil {
|
||||
return modelsResponse{}, errors.New("response has no data list")
|
||||
}
|
||||
return mr, nil
|
||||
}
|
||||
|
||||
// Verify checks the credentials with the cheapest authenticated call: one
|
||||
// page of GET {base}/v1/models holding at most one model.
|
||||
func (p *Provider) Verify(ctx context.Context) error {
|
||||
_, err := p.modelsPage(ctx, 1, "")
|
||||
if err != nil {
|
||||
err = fmt.Errorf("anthropic: verify: %w", err)
|
||||
}
|
||||
return redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
// do sends one request and returns the body of a 2xx response. Any other
|
||||
// status becomes the matching llm typed error.
|
||||
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if payload != nil {
|
||||
reqBody = bytes.NewReader(payload)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||
if err != nil {
|
||||
// The parse error names the URL, whose path may hold the API key.
|
||||
return nil, errors.New("build request: request URL is invalid")
|
||||
}
|
||||
httpReq.Header.Set("x-api-key", p.apiKey)
|
||||
httpReq.Header.Set("anthropic-version", APIVersion)
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
httpResp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
return nil, p.statusError(httpResp, body)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
if len(body) > maxResponseBody {
|
||||
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// statusOverloaded is the status the Messages API returns when it is
|
||||
// temporarily overloaded.
|
||||
const statusOverloaded = 529
|
||||
|
||||
// statusError maps a non-2xx response to an llm typed error.
|
||||
func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
||||
case http.StatusTooManyRequests, statusOverloaded:
|
||||
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||
case http.StatusRequestTimeout:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
|
||||
default:
|
||||
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
|
||||
// form. A missing, malformed or past value means the vendor gave no usable
|
||||
// wait, reported as 0.
|
||||
func retryAfter(value string, now time.Time) time.Duration {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.Atoi(value); err == nil {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||
return at.Sub(now).Round(time.Second)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
}
|
||||
|
||||
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||
// with the request URL removed and the API key redacted, and unwraps only to
|
||||
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||
type transportFailure struct {
|
||||
detail string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string {
|
||||
if e.kind == llm.ErrTimeout {
|
||||
return fmt.Sprintf("%s: transport: %s", llm.ErrTimeout, e.detail)
|
||||
}
|
||||
return "transport: " + e.detail
|
||||
}
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
// transportError reports a failure to complete the HTTP exchange. A
|
||||
// *url.Error names the request URL, and a base URL path may hold the API
|
||||
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||
func (p *Provider) transportError(err error) error {
|
||||
var kind error
|
||||
switch {
|
||||
case isTimeout(err):
|
||||
kind = llm.ErrTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
kind = context.Canceled
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
return &transportFailure{detail: llm.Redact(err.Error(), p.apiKey), kind: kind}
|
||||
}
|
||||
|
||||
// redactedError stands in for one layer of an error chain that holds the
|
||||
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||
// returns the layer's causes passed through redact, so no error reached by
|
||||
// unwrapping has text holding the key. Is consults the original layer and
|
||||
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||
// remove the key from their own complete text, fixed words included, so
|
||||
// they always match; As refuses only a match from outside llm whose text
|
||||
// holds the key, so it never hands back an unredacted error.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
key string
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v from printing the fields, which hold the key.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
func (e *redactedError) As(target any) bool {
|
||||
if !errors.As(e.err, target) {
|
||||
return false
|
||||
}
|
||||
found := reflect.ValueOf(target).Elem()
|
||||
if matched, ok := found.Interface().(error); ok && holdsKey(matched, e.key) {
|
||||
found.SetZero()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redactedJoin is redactedError for a layer with several causes, such as
|
||||
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||
type redactedJoin struct {
|
||||
redactedError
|
||||
}
|
||||
|
||||
func (e *redactedJoin) Unwrap() []error {
|
||||
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||
safe := make([]error, len(causes))
|
||||
for i, c := range causes {
|
||||
safe[i] = redact(c, e.key)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// redact is the backstop every exported method applies to its error: when
|
||||
// the text of err or of any error in its chain holds a non-empty key, it
|
||||
// returns a stand-in whose text, and the text of every error it unwraps
|
||||
// to, has the key redacted. Any other err is returned unchanged.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" || !holdsKey(err, key) {
|
||||
return err
|
||||
}
|
||||
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
return &redactedJoin{re}
|
||||
}
|
||||
return &re
|
||||
}
|
||||
|
||||
// holdsKey reports whether the text of err or of any error in its chain
|
||||
// contains key.
|
||||
func holdsKey(err error, key string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), key) {
|
||||
return true
|
||||
}
|
||||
switch u := err.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
return holdsKey(u.Unwrap(), key)
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
if holdsKey(c, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
const testKey = "sk-ant-test-9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||
|
||||
// recorded is one request the test server received.
|
||||
type recorded struct {
|
||||
method string
|
||||
path string
|
||||
query url.Values
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// reply is a response the test server replays.
|
||||
type reply struct {
|
||||
status int
|
||||
header map[string]string
|
||||
fixture string // testdata file; {{KEY}} is replaced with the key
|
||||
body string // used when fixture is empty
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// vendorServer replays its replies in order, repeating the last, and
|
||||
// records every request.
|
||||
type vendorServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
got []recorded
|
||||
}
|
||||
|
||||
func newVendorServer(t *testing.T, replies ...reply) *vendorServer {
|
||||
return newVendorServerWithKey(t, testKey, replies...)
|
||||
}
|
||||
|
||||
func newVendorServerWithKey(t *testing.T, key string, replies ...reply) *vendorServer {
|
||||
t.Helper()
|
||||
payloads := make([][]byte, len(replies))
|
||||
for i, r := range replies {
|
||||
payloads[i] = []byte(r.body)
|
||||
if r.fixture != "" {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", r.fixture))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payloads[i] = bytes.ReplaceAll(raw, []byte("{{KEY}}"), []byte(key))
|
||||
}
|
||||
}
|
||||
vs := &vendorServer{}
|
||||
vs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
vs.mu.Lock()
|
||||
n := len(vs.got)
|
||||
vs.got = append(vs.got, recorded{req.Method, req.URL.Path, req.URL.Query(), req.Header.Clone(), body})
|
||||
vs.mu.Unlock()
|
||||
i := min(n, len(replies)-1)
|
||||
r := replies[i]
|
||||
if r.delay > 0 {
|
||||
select {
|
||||
case <-time.After(r.delay):
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
for k, v := range r.header {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(r.status)
|
||||
w.Write(payloads[i])
|
||||
}))
|
||||
t.Cleanup(vs.Close)
|
||||
return vs
|
||||
}
|
||||
|
||||
func (vs *vendorServer) requests() []recorded {
|
||||
vs.mu.Lock()
|
||||
defer vs.mu.Unlock()
|
||||
return append([]recorded(nil), vs.got...)
|
||||
}
|
||||
|
||||
func (vs *vendorServer) only(t *testing.T) recorded {
|
||||
t.Helper()
|
||||
got := vs.requests()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("server got %d requests, want 1", len(got))
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, base string) *Provider {
|
||||
return newProviderWithKey(t, base, testKey)
|
||||
}
|
||||
|
||||
func newProviderWithKey(t *testing.T, base, key string) *Provider {
|
||||
t.Helper()
|
||||
p, err := New(Config{BaseURL: base, APIKey: key, Model: "test-model", MaxTokens: 1024})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"]}`)
|
||||
|
||||
func schemaRequest() llm.Request {
|
||||
temp := 0.0
|
||||
return llm.Request{
|
||||
System: "You plan dinners for Our kitchen.",
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "Suggest a Side quest."},
|
||||
{Role: llm.RoleAssistant, Content: "Something quick?"},
|
||||
{Role: llm.RoleUser, Content: "Yes, a Quick dinner."},
|
||||
},
|
||||
Temperature: &temp,
|
||||
MaxTokens: 400,
|
||||
Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema},
|
||||
}
|
||||
}
|
||||
|
||||
func textRequest() llm.Request {
|
||||
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "What should we cook tonight?"}}}
|
||||
}
|
||||
|
||||
// sentBody decodes a request body into a generic map.
|
||||
func sentBody(t *testing.T, r recorded) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(r.body, &m); err != nil {
|
||||
t.Fatalf("request body %q: %v", r.body, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// canonical re-encodes JSON so two encodings of one value compare equal.
|
||||
func canonical(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("JSON %q: %v", raw, err)
|
||||
}
|
||||
out, _ := json.Marshal(v)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func assertHeaders(t *testing.T, r recorded, key string) {
|
||||
t.Helper()
|
||||
if got := r.header.Values("X-Api-Key"); len(got) != 1 || got[0] != key {
|
||||
t.Errorf("x-api-key = %q", got)
|
||||
}
|
||||
if got := r.header.Values("Anthropic-Version"); len(got) != 1 || got[0] != "2023-06-01" {
|
||||
t.Errorf("anthropic-version = %q", got)
|
||||
}
|
||||
if got := r.header.Get("Authorization"); got != "" {
|
||||
t.Errorf("Authorization = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToolForcedJSON(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_tool_use.json"})
|
||||
p := newProvider(t, vs.URL+"/")
|
||||
|
||||
resp, err := p.Generate(context.Background(), schemaRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := canonical(t, resp.JSON), `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}`; got != want {
|
||||
t.Errorf("Response.JSON = %s, want %s", got, want)
|
||||
}
|
||||
if resp.Text != "" {
|
||||
t.Errorf("Text = %q, want none", resp.Text)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 472, OutputTokens: 61}) || resp.Model != "claude-sonnet-5" || resp.FinishReason != "tool_use" {
|
||||
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := vs.only(t)
|
||||
if got.method != http.MethodPost || got.path != "/v1/messages" || len(got.query) != 0 {
|
||||
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertHeaders(t, got, testKey)
|
||||
if h := got.header.Get("Content-Type"); h != "application/json" {
|
||||
t.Errorf("Content-Type = %q", h)
|
||||
}
|
||||
body := sentBody(t, got)
|
||||
if body["model"] != "test-model" || body["temperature"] != 0.0 || body["max_tokens"] != 400.0 {
|
||||
t.Errorf("model, temperature, max_tokens = %v, %v, %v", body["model"], body["temperature"], body["max_tokens"])
|
||||
}
|
||||
if body["system"] != "You plan dinners for Our kitchen." {
|
||||
t.Errorf("system = %v", body["system"])
|
||||
}
|
||||
msgs := body["messages"].([]any)
|
||||
wantMsgs := []string{"user:Suggest a Side quest.", "assistant:Something quick?", "user:Yes, a Quick dinner."}
|
||||
if len(msgs) != len(wantMsgs) {
|
||||
t.Fatalf("messages = %v", msgs)
|
||||
}
|
||||
for i, m := range msgs {
|
||||
mm := m.(map[string]any)
|
||||
if got := fmt.Sprintf("%v:%v", mm["role"], mm["content"]); got != wantMsgs[i] {
|
||||
t.Errorf("message %d = %s, want %s", i, got, wantMsgs[i])
|
||||
}
|
||||
}
|
||||
tools := body["tools"].([]any)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("tools = %v", tools)
|
||||
}
|
||||
tool := tools[0].(map[string]any)
|
||||
schema, _ := json.Marshal(tool["input_schema"])
|
||||
if tool["name"] != "side_quests" || string(schema) != canonical(t, dinnersSchema) {
|
||||
t.Errorf("tool = %v", tool)
|
||||
}
|
||||
choice := body["tool_choice"].(map[string]any)
|
||||
if len(choice) != 2 || choice["type"] != "tool" || choice["name"] != "side_quests" {
|
||||
t.Errorf("tool_choice = %v", choice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateText(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_text.json"})
|
||||
p := newProvider(t, vs.URL)
|
||||
|
||||
resp, err := p.Generate(context.Background(), textRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Text != "Try a crock pot chili: it is a Quick dinner." || resp.JSON != nil {
|
||||
t.Errorf("Text, JSON = %q, %s", resp.Text, resp.JSON)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 2095, OutputTokens: 503}) || resp.Model != "claude-sonnet-5" || resp.FinishReason != "end_turn" {
|
||||
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := vs.only(t)
|
||||
if got.path != "/v1/messages" {
|
||||
t.Errorf("path = %s", got.path)
|
||||
}
|
||||
assertHeaders(t, got, testKey)
|
||||
body := sentBody(t, got)
|
||||
if body["max_tokens"] != 1024.0 {
|
||||
t.Errorf("max_tokens = %v, want the configured 1024", body["max_tokens"])
|
||||
}
|
||||
for _, field := range []string{"system", "temperature", "tools", "tool_choice"} {
|
||||
if v, ok := body[field]; ok {
|
||||
t.Errorf("%s = %v, want it omitted", field, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTextKeepsStopReason(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_max_tokens.json"})
|
||||
resp, err := newProvider(t, vs.URL).Generate(context.Background(), textRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.FinishReason != "max_tokens" || resp.Usage.OutputTokens != 400 {
|
||||
t.Errorf("FinishReason, Usage = %q, %+v", resp.FinishReason, resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBadOutput(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"text instead of tool": `{"type":"message","model":"m","content":[{"type":"text","text":"Sure!"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`,
|
||||
"other tool": `{"type":"message","model":"m","content":[{"type":"tool_use","id":"t","name":"other","input":{"a":1}}],"stop_reason":"tool_use","usage":{}}`,
|
||||
"input not an object": `{"type":"message","model":"m","content":[{"type":"tool_use","id":"t","name":"side_quests","input":[1]}],"stop_reason":"tool_use","usage":{}}`,
|
||||
"no content": `{"type":"message","model":"m","content":[],"stop_reason":"max_tokens","usage":{}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, body: body})
|
||||
resp, err := newProvider(t, vs.URL).Generate(context.Background(), schemaRequest())
|
||||
if !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want ErrBadOutput", err)
|
||||
}
|
||||
if resp.JSON != nil || resp.Model != "m" {
|
||||
t.Fatalf("Response = %+v, want no JSON and the model kept", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_max_tokens.json"})
|
||||
resp, err := newProvider(t, vs.URL).Generate(context.Background(), schemaRequest())
|
||||
if !errors.Is(err, llm.ErrBadOutput) || resp.FinishReason != "max_tokens" {
|
||||
t.Fatalf("truncated: resp, err = %+v, %v", resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsNonMessage(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "error_overloaded.json"})
|
||||
_, err := newProvider(t, vs.URL).Generate(context.Background(), textRequest())
|
||||
if err == nil || errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want an untyped malformed-response error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsInvalidRequests(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_text.json"})
|
||||
p := newProvider(t, vs.URL)
|
||||
if _, err := p.Generate(context.Background(), llm.Request{}); err == nil {
|
||||
t.Error("Generate accepted a request with no messages")
|
||||
}
|
||||
for _, name := range []string{"side quests", "", strings.Repeat("a", 65), "plan.v2"} {
|
||||
req := schemaRequest()
|
||||
req.Schema.Name = name
|
||||
if _, err := p.Generate(context.Background(), req); err == nil {
|
||||
t.Errorf("Generate accepted tool name %q", name)
|
||||
}
|
||||
}
|
||||
noModel, err := New(Config{BaseURL: vs.URL, APIKey: testKey, MaxTokens: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := noModel.Generate(context.Background(), textRequest()); err == nil {
|
||||
t.Error("Generate succeeded with no model configured")
|
||||
}
|
||||
if n := len(vs.requests()); n != 0 {
|
||||
t.Errorf("invalid requests reached the vendor %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModelsFollowsPages(t *testing.T) {
|
||||
vs := newVendorServer(t,
|
||||
reply{status: 200, fixture: "models_page1.json"},
|
||||
reply{status: 200, fixture: "models_page2.json"},
|
||||
)
|
||||
models, err := newProvider(t, vs.URL).ListModels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []llm.Model{
|
||||
{ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
|
||||
{ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5"},
|
||||
{ID: "claude-opus-5-5", DisplayName: "claude-opus-5-5"},
|
||||
}
|
||||
if len(models) != len(want) {
|
||||
t.Fatalf("models = %+v", models)
|
||||
}
|
||||
for i := range want {
|
||||
if models[i] != want[i] {
|
||||
t.Errorf("model %d = %+v, want %+v", i, models[i], want[i])
|
||||
}
|
||||
}
|
||||
got := vs.requests()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("server got %d requests, want 2", len(got))
|
||||
}
|
||||
for i, wantAfter := range []string{"", "claude-sonnet-5"} {
|
||||
r := got[i]
|
||||
if r.method != http.MethodGet || r.path != "/v1/models" || r.query.Get("limit") != "1000" || r.query.Get("after_id") != wantAfter {
|
||||
t.Errorf("request %d = %s %s?%s", i, r.method, r.path, r.query.Encode())
|
||||
}
|
||||
assertHeaders(t, r, testKey)
|
||||
if len(r.body) != 0 || r.header.Get("Content-Type") != "" {
|
||||
t.Errorf("request %d sent a body", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModelsRejectsBadPages(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"no data": `{"has_more":false}`,
|
||||
"no id": `{"data":[{"type":"model","display_name":"x"}],"has_more":false}`,
|
||||
"no cursor": `{"data":[{"id":"a"}],"has_more":true}`,
|
||||
"stuck cursor": `{"data":[{"id":"a"}],"has_more":true,"last_id":"a"}`,
|
||||
"invalid": `{"data":`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
vs := newVendorServer(t, reply{status: 200, body: body})
|
||||
if models, err := newProvider(t, vs.URL).ListModels(context.Background()); err == nil {
|
||||
t.Errorf("%s: ListModels() = %+v, nil error", name, models)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModelsEmpty(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, body: `{"data":[],"has_more":false}`})
|
||||
models, err := newProvider(t, vs.URL).ListModels(context.Background())
|
||||
if err != nil || models == nil || len(models) != 0 {
|
||||
t.Fatalf("ListModels() = %#v, %v", models, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
ok := newVendorServer(t, reply{status: 200, fixture: "models_page1.json"})
|
||||
if err := newProvider(t, ok.URL).Verify(context.Background()); err != nil {
|
||||
t.Fatalf("Verify() = %v", err)
|
||||
}
|
||||
got := ok.only(t)
|
||||
if got.method != http.MethodGet || got.path != "/v1/models" || got.query.Get("limit") != "1" || got.query.Has("after_id") {
|
||||
t.Errorf("Verify called %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertHeaders(t, got, testKey)
|
||||
|
||||
for status, want := range map[int]error{401: llm.ErrAuth, 403: llm.ErrAuth, 529: llm.ErrRateLimited, 500: llm.ErrProvider} {
|
||||
bad := newVendorServer(t, reply{status: status, fixture: "error_api.json"})
|
||||
err := newProvider(t, bad.URL).Verify(context.Background())
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("HTTP %d: Verify() = %v, want %v", status, err, want)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
}
|
||||
malformed := newVendorServer(t, reply{status: 200, body: `{}`})
|
||||
if err := newProvider(t, malformed.URL).Verify(context.Background()); err == nil {
|
||||
t.Error("Verify accepted a body with no data list")
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSecret fails if err's text holds the key or a prefix of it.
|
||||
func assertNoSecret(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{testKey, testKey[:16]} {
|
||||
if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(secret)) {
|
||||
t.Fatalf("error leaks %q: %v", secret, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMapping(t *testing.T) {
|
||||
rateLimited := func(want time.Duration) func(*testing.T, error) {
|
||||
return func(t *testing.T, err error) {
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.As(err, &rl) || rl.RetryAfter != want {
|
||||
t.Fatalf("RetryAfter from %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
providerError := func(status int, excerpt string) func(*testing.T, error) {
|
||||
return func(t *testing.T, err error) {
|
||||
var pe *llm.ProviderError
|
||||
if !errors.As(err, &pe) || pe.Status() != status || !strings.Contains(pe.Body(), excerpt) {
|
||||
t.Fatalf("ProviderError from %v, want HTTP %d with %q", err, status, excerpt)
|
||||
}
|
||||
if !strings.Contains(err.Error(), pe.Body()) {
|
||||
t.Fatalf("Error() = %q, want it to carry the excerpt", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
reply reply
|
||||
want error
|
||||
check func(*testing.T, error)
|
||||
}{
|
||||
{name: "401", reply: reply{status: 401, fixture: "error_authentication.json"}, want: llm.ErrAuth},
|
||||
{name: "403", reply: reply{status: 403, fixture: "error_permission.json"}, want: llm.ErrAuth},
|
||||
{name: "429 seconds", reply: reply{status: 429, header: map[string]string{"Retry-After": "17"}, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited, check: rateLimited(17 * time.Second)},
|
||||
{name: "429 no Retry-After", reply: reply{status: 429, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited, check: rateLimited(0)},
|
||||
{name: "429 HTTP-date", reply: reply{status: 429, header: map[string]string{"Retry-After": time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat)}, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited,
|
||||
check: func(t *testing.T, err error) {
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.As(err, &rl) || rl.RetryAfter < 85*time.Second || rl.RetryAfter > 91*time.Second {
|
||||
t.Fatalf("RetryAfter from %v", err)
|
||||
}
|
||||
}},
|
||||
{name: "529 seconds", reply: reply{status: 529, header: map[string]string{"Retry-After": "4"}, fixture: "error_overloaded.json"}, want: llm.ErrRateLimited, check: rateLimited(4 * time.Second)},
|
||||
{name: "529 no Retry-After", reply: reply{status: 529, fixture: "error_overloaded.json"}, want: llm.ErrRateLimited, check: rateLimited(0)},
|
||||
{name: "408", reply: reply{status: 408}, want: llm.ErrTimeout},
|
||||
{name: "400", reply: reply{status: 400, fixture: "error_invalid_request.json"}, want: llm.ErrProvider, check: providerError(400, "max_tokens: Field required")},
|
||||
{name: "404", reply: reply{status: 404, fixture: "error_not_found.json"}, want: llm.ErrProvider, check: providerError(404, "not_found_error")},
|
||||
{name: "500 echoing the key", reply: reply{status: 500, fixture: "error_api.json"}, want: llm.ErrProvider, check: providerError(500, "x-api-key: [REDACTED]")},
|
||||
{name: "503", reply: reply{status: 503, body: "upstream connect error"}, want: llm.ErrProvider, check: providerError(503, "upstream connect error")},
|
||||
}
|
||||
all := []error{llm.ErrAuth, llm.ErrRateLimited, llm.ErrTimeout, llm.ErrBadOutput, llm.ErrProvider}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
vs := newVendorServer(t, c.reply)
|
||||
p := newProvider(t, vs.URL)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
for _, err := range []error{genErr, listErr} {
|
||||
for _, sentinel := range all {
|
||||
if errors.Is(err, sentinel) != (sentinel == c.want) {
|
||||
t.Fatalf("errors.Is(%v, %v) = %v", err, sentinel, sentinel != c.want)
|
||||
}
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
if c.check != nil {
|
||||
c.check(t, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeouts(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_tool_use.json", delay: 5 * time.Second})
|
||||
p := newProvider(t, vs.URL)
|
||||
|
||||
req := schemaRequest()
|
||||
req.Timeout = 50 * time.Millisecond
|
||||
start := time.Now()
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("Request.Timeout: err = %v, want ErrTimeout", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("Request.Timeout not honoured: took %v", elapsed)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := p.ListModels(ctx); !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("context deadline: err = %v, want ErrTimeout", err)
|
||||
}
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := p.Verify(ctx); !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("Verify context deadline: err = %v, want ErrTimeout", err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := p.Generate(ctx, schemaRequest()); !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("cancelled context: err = %v, want context.Canceled only", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransportErrorWithKeyInBaseURLPath fails the exchange against a base
|
||||
// URL whose path holds the key, which New accepts; net/http names the full
|
||||
// request URL in its transport errors.
|
||||
func TestTransportErrorWithKeyInBaseURLPath(t *testing.T) {
|
||||
refused := newVendorServer(t, reply{status: 200})
|
||||
refusedBase := refused.URL
|
||||
refused.Close()
|
||||
|
||||
dropped := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}))
|
||||
t.Cleanup(dropped.Close)
|
||||
|
||||
for name, base := range map[string]string{
|
||||
"connection refused": refusedBase + "/" + testKey,
|
||||
"connection dropped": dropped.URL + "/" + testKey,
|
||||
} {
|
||||
p := newProvider(t, base)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: nil error", name)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
t.Fatalf("%s: error chain exposes the request URL: %v", name, ue)
|
||||
}
|
||||
if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrProvider) {
|
||||
t.Fatalf("%s: err = %v, want an untyped transport failure", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The request URL escapes a key with spaces, so redacting the key
|
||||
// itself would miss that copy.
|
||||
const spacedKey = "sk ant key"
|
||||
p := newProviderWithKey(t, refusedBase+"/"+spacedKey, spacedKey)
|
||||
_, genErr := p.Generate(context.Background(), textRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatal("escaped key: nil error")
|
||||
}
|
||||
assertChainNoExactKey(t, err, spacedKey)
|
||||
assertChainNoExactKey(t, err, url.PathEscape(spacedKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadConfig(t *testing.T) {
|
||||
cases := map[string]Config{
|
||||
"empty base": {},
|
||||
"relative base": {BaseURL: "api.anthropic.com"},
|
||||
"ftp base": {BaseURL: "ftp://example.com"},
|
||||
"credentials in URL": {BaseURL: "https://user:" + testKey + "@example.com"},
|
||||
"key in query": {BaseURL: "https://example.com/?key=" + testKey},
|
||||
"key in fragment": {BaseURL: "https://example.com/#" + testKey},
|
||||
"key, relative base": {BaseURL: "example.com?key=" + testKey},
|
||||
"key, ftp base": {BaseURL: "ftp://example.com/?key=" + testKey},
|
||||
"key, unparseable": {BaseURL: "https://example.com/%zz?key=" + testKey},
|
||||
"empty query": {BaseURL: "https://example.com?"},
|
||||
"empty fragment": {BaseURL: "https://example.com#"},
|
||||
"empty both": {BaseURL: "https://example.com/?#"},
|
||||
"empty key": {BaseURL: "https://example.com", APIKey: "-"},
|
||||
"key with newline": {BaseURL: "https://example.com", APIKey: testKey + "\n"},
|
||||
"zero max tokens": {BaseURL: "https://example.com", MaxTokens: -1},
|
||||
"negative max": {BaseURL: "https://example.com", MaxTokens: -5},
|
||||
}
|
||||
for name, cfg := range cases {
|
||||
switch cfg.APIKey {
|
||||
case "":
|
||||
cfg.APIKey = testKey
|
||||
case "-":
|
||||
cfg.APIKey = ""
|
||||
}
|
||||
switch cfg.MaxTokens {
|
||||
case 0:
|
||||
cfg.MaxTokens = 1024
|
||||
case -1:
|
||||
cfg.MaxTokens = 0
|
||||
}
|
||||
_, err := New(cfg)
|
||||
if err == nil {
|
||||
t.Errorf("%s: New() = nil error", name)
|
||||
continue
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
}
|
||||
if _, err := New(Config{BaseURL: "http://anthropic:8080/proxy/", APIKey: testKey, MaxTokens: 1}); err != nil {
|
||||
t.Errorf("New() rejected a valid config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryAfter(t *testing.T) {
|
||||
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
||||
cases := map[string]time.Duration{
|
||||
"": 0,
|
||||
"30": 30 * time.Second,
|
||||
" 5 ": 5 * time.Second,
|
||||
"-3": 0,
|
||||
"soon": 0,
|
||||
"Sat, 03 Oct 2026 12:02:00 GMT": 2 * time.Minute,
|
||||
"Sat, 03 Oct 2026 11:59:00 GMT": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := retryAfter(in, now); got != want {
|
||||
t.Errorf("retryAfter(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertChainNoExactKey fails if key appears, case-sensitively, in the text
|
||||
// of err, of any error reached from it by unwrapping, or of a typed error
|
||||
// errors.As finds in it, including a ProviderError's Body.
|
||||
func assertChainNoExactKey(t *testing.T, err error, key string) {
|
||||
t.Helper()
|
||||
var walk func(error)
|
||||
walk = func(e error) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if strings.Contains(e.Error(), key) {
|
||||
t.Fatalf("layer leaks %q: %q", key, e.Error())
|
||||
}
|
||||
switch u := e.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
walk(u.Unwrap())
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(err)
|
||||
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
|
||||
if strings.Contains(layer.Error(), key) {
|
||||
t.Fatalf("errors.Unwrap layer leaks %q: %q", key, layer.Error())
|
||||
}
|
||||
}
|
||||
var pe *llm.ProviderError
|
||||
if errors.As(err, &pe) && (strings.Contains(pe.Body(), key) || strings.Contains(pe.Error(), key)) {
|
||||
t.Fatalf("ProviderError leaks %q: Error %q, Body %q", key, pe.Error(), pe.Body())
|
||||
}
|
||||
var rl *llm.RateLimitedError
|
||||
if errors.As(err, &rl) && strings.Contains(rl.Error(), key) {
|
||||
t.Fatalf("RateLimitedError leaks %q: %q", key, rl.Error())
|
||||
}
|
||||
var syntax *json.SyntaxError
|
||||
if errors.As(err, &syntax) && strings.Contains(syntax.Error(), key) {
|
||||
t.Fatalf("errors.As reached a *json.SyntaxError leaking %q: %q", key, syntax.Error())
|
||||
}
|
||||
if got := fmt.Sprintf("%v %+v %#v", err, err, err); strings.Contains(got, key) {
|
||||
t.Fatalf("formatting leaks %q: %q", key, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderNameKeyNeverLeaks uses the header name "x-api-key" as the API
|
||||
// key, a word an error naming the header would print, and drives every
|
||||
// construction and request error path with it.
|
||||
func TestHeaderNameKeyNeverLeaks(t *testing.T) {
|
||||
const key = "x-api-key"
|
||||
construction := map[string]Config{
|
||||
"unparseable base": {BaseURL: "https://example.com/%zz/" + key, APIKey: key, MaxTokens: 1},
|
||||
"relative base": {BaseURL: key, APIKey: key, MaxTokens: 1},
|
||||
"query in base": {BaseURL: "https://example.com/?" + key, APIKey: key, MaxTokens: 1},
|
||||
"userinfo in base": {BaseURL: "https://" + key + "@example.com", APIKey: key, MaxTokens: 1},
|
||||
"no max tokens": {BaseURL: "https://example.com", APIKey: key},
|
||||
"key with newline": {BaseURL: "https://example.com", APIKey: key + "\r\n", MaxTokens: 1},
|
||||
}
|
||||
for name, cfg := range construction {
|
||||
_, err := New(cfg)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: New() = nil error", name)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
body := `{"type":"error","error":{"type":"x","message":"bad x-api-key header: x-api-key"}}`
|
||||
statuses := []int{400, 401, 403, 404, 408, 429, 500, 529}
|
||||
for _, status := range statuses {
|
||||
vs := newVendorServerWithKey(t, key, reply{status: status, body: body, header: map[string]string{"Retry-After": "3"}})
|
||||
p := newProviderWithKey(t, vs.URL, key)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP %d: nil error", status)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
}
|
||||
|
||||
ok := newVendorServerWithKey(t, key, reply{status: 200, fixture: "messages_text.json"})
|
||||
p := newProviderWithKey(t, ok.URL, key)
|
||||
requests := map[string]llm.Request{
|
||||
"unknown role": {Messages: []llm.Message{{Role: llm.Role(key), Content: "hi"}}},
|
||||
"bad tool name": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key + "!", Schema: dinnersSchema}},
|
||||
"schema array": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key, Schema: json.RawMessage(`[]`)}},
|
||||
"bad output": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key, Schema: dinnersSchema}},
|
||||
}
|
||||
for name, req := range requests {
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: nil error", name)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
noModel, err := New(Config{BaseURL: ok.URL, APIKey: key, MaxTokens: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = noModel.Generate(context.Background(), textRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Generate succeeded with no model")
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
|
||||
refused := newVendorServer(t, reply{status: 200})
|
||||
refusedBase := refused.URL
|
||||
refused.Close()
|
||||
_, err = newProviderWithKey(t, refusedBase+"/"+key, key).Generate(context.Background(), textRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Generate against a closed server succeeded")
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestAdapterNameKeyOn401 uses the adapter's own error prefix "anthropic" as
|
||||
// the API key.
|
||||
func TestAdapterNameKeyOn401(t *testing.T) {
|
||||
const key = "anthropic"
|
||||
vs := newVendorServerWithKey(t, key, reply{status: 401, fixture: "error_authentication.json"})
|
||||
p := newProviderWithKey(t, vs.URL, key)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if !errors.Is(err, llm.ErrAuth) {
|
||||
t.Fatalf("err = %v, want ErrAuth", err)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixedWordKeyOn500 uses the fixed word "HTTP" of ProviderError's text
|
||||
// as the API key: errors.As must still reach the ProviderError, whose Body
|
||||
// and Error must drop it.
|
||||
func TestFixedWordKeyOn500(t *testing.T) {
|
||||
const key = "HTTP"
|
||||
vs := newVendorServerWithKey(t, key, reply{status: 500, body: `{"type":"error","error":{"type":"api_error","message":"HTTP backend down"}}`})
|
||||
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), schemaRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
|
||||
t.Fatalf("err = %v, want a 500 ProviderError", err)
|
||||
}
|
||||
if strings.Contains(pe.Error(), key) || strings.Contains(pe.Body(), key) {
|
||||
t.Fatalf("ProviderError leaks the key: Error %q, Body %q", pe.Error(), pe.Body())
|
||||
}
|
||||
if !strings.Contains(pe.Body(), "backend down") {
|
||||
t.Fatalf("Body() = %q, want the redacted excerpt", pe.Body())
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestRateLimitedFixedWordKey uses the fixed word "rate" of
|
||||
// RateLimitedError's text as the API key, on both rate-limit statuses.
|
||||
func TestRateLimitedFixedWordKey(t *testing.T) {
|
||||
const key = "rate"
|
||||
for _, status := range []int{429, 529} {
|
||||
vs := newVendorServerWithKey(t, key, reply{status: status, header: map[string]string{"Retry-After": "17"}, fixture: "error_rate_limit.json"})
|
||||
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), schemaRequest())
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != 17*time.Second {
|
||||
t.Fatalf("HTTP %d: err = %v, want a RateLimitedError with RetryAfter 17s", status, err)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponseBodyWithKeyNeverLeaks has the vendor echo the key in error
|
||||
// and success bodies, including ones that fail to decode, and checks
|
||||
// Error(), every errors.Unwrap layer and every errors.As target.
|
||||
func TestResponseBodyWithKeyNeverLeaks(t *testing.T) {
|
||||
bodies := []reply{
|
||||
{status: 500, fixture: "error_api.json"},
|
||||
{status: 400, body: `{"type":"error","error":{"type":"invalid_request_error","message":"key ` + testKey + ` rejected"}}`},
|
||||
{status: 401, fixture: "error_authentication.json"},
|
||||
{status: 429, fixture: "error_rate_limit.json"},
|
||||
{status: 200, body: `{"type": ` + testKey + `}`},
|
||||
{status: 200, body: testKey},
|
||||
{status: 200, body: `{"type":"message","content":[{"type":"text","text":"` + testKey + `"}],"stop_reason":"` + testKey + `"}`},
|
||||
{status: 200, body: `{"data": [` + testKey + `]}`},
|
||||
}
|
||||
for i, r := range bodies {
|
||||
vs := newVendorServer(t, r)
|
||||
p := newProvider(t, vs.URL)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatalf("body %d: nil error", i)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
}
|
||||
}
|
||||
// A short key that decode errors quote from a malformed body.
|
||||
const key = "Z"
|
||||
vs := newVendorServerWithKey(t, key, reply{status: 200, body: `{"type": Z}`})
|
||||
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), textRequest())
|
||||
var syntax *json.SyntaxError
|
||||
if err == nil || errors.As(err, &syntax) {
|
||||
t.Fatalf("err = %v, want no reachable *json.SyntaxError quoting the key", err)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestRedactKeepsChainMatches checks that the redaction backstop keeps
|
||||
// errors.Is and errors.As working through a chain that holds the key.
|
||||
func TestRedactKeepsChainMatches(t *testing.T) {
|
||||
pe := llm.NewProviderError(500, []byte(`{"error":"down"}`), testKey)
|
||||
rl := llm.NewRateLimitedError(time.Second, testKey)
|
||||
inner := fmt.Errorf("decode %s: %w", testKey, llm.ErrBadOutput)
|
||||
joined := errors.Join(inner, pe, rl, context.Canceled, &transportFailure{detail: "slow", kind: llm.ErrTimeout}, fmt.Errorf("HTTP 401: %w", llm.ErrAuth))
|
||||
err := redact(fmt.Errorf("anthropic %s: %w", testKey, joined), testKey)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
for _, target := range []error{llm.ErrBadOutput, llm.ErrProvider, llm.ErrRateLimited, llm.ErrTimeout, llm.ErrAuth, context.Canceled} {
|
||||
if !errors.Is(err, target) {
|
||||
t.Fatalf("errors.Is(%v, %v) = false", err, target)
|
||||
}
|
||||
}
|
||||
var gotPE *llm.ProviderError
|
||||
var gotRL *llm.RateLimitedError
|
||||
if !errors.As(err, &gotPE) || gotPE != pe || !errors.As(err, &gotRL) || gotRL != rl {
|
||||
t.Fatalf("errors.As lost a typed error in %v", err)
|
||||
}
|
||||
clean := errors.New("anthropic: generate: no model configured")
|
||||
if redact(clean, testKey) != clean {
|
||||
t.Fatal("redact wrapped an error that holds no key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactedErrorsFormatWithoutKey formats every error type that holds the
|
||||
// key, or a cause holding it, with %v, %+v and %#v; %#v prints GoString.
|
||||
func TestRedactedErrorsFormatWithoutKey(t *testing.T) {
|
||||
const key = "sk-distinctive-anthropic-format-7c1e"
|
||||
single := redact(fmt.Errorf("anthropic %s: %w", key, llm.ErrBadOutput), key)
|
||||
joined := redact(errors.Join(fmt.Errorf("decode %s: %w", key, llm.ErrAuth), llm.NewProviderError(500, []byte("down "+key), key)), key)
|
||||
if _, ok := single.(*redactedError); !ok {
|
||||
t.Fatalf("single = %T, want *redactedError", single)
|
||||
}
|
||||
if _, ok := joined.(*redactedJoin); !ok {
|
||||
t.Fatalf("joined = %T, want *redactedJoin", joined)
|
||||
}
|
||||
p := &Provider{apiKey: key}
|
||||
transport := p.transportError(fmt.Errorf("dial %s: %w", key, context.DeadlineExceeded))
|
||||
for _, err := range []error{single, joined, transport} {
|
||||
for _, verb := range []string{"%v", "%+v", "%#v"} {
|
||||
if got := fmt.Sprintf(verb, err); strings.Contains(got, key) {
|
||||
t.Fatalf("%s of %T leaks the key: %q", verb, err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Anthropic adapter fixtures
|
||||
|
||||
These are the Messages API response bodies the contract tests in
|
||||
`anthropic_test.go` replay. They are **not live captures**. Product AI is
|
||||
tested with fakes, and live vendor calls are opt-in and never use
|
||||
credentials, so no request was sent to Anthropic to make them. Each fixture
|
||||
is a sanitized adaptation of the response shapes in Anthropic's public API
|
||||
documentation:
|
||||
|
||||
- Messages: <https://docs.anthropic.com/en/api/messages>
|
||||
- Tool use, including `tool_choice` `{type: tool}`:
|
||||
<https://docs.anthropic.com/en/docs/build-with-claude/tool-use>
|
||||
- List models: <https://docs.anthropic.com/en/api/models-list>
|
||||
- Errors: <https://docs.anthropic.com/en/api/errors>
|
||||
|
||||
Sanitizing:
|
||||
|
||||
- IDs, request IDs, timestamps and token counts are illustrative values in
|
||||
the documented format, not values from a real account.
|
||||
- Message content and tool input are replaced with MealQuest dinner text.
|
||||
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||
a vendor body.
|
||||
|
||||
Below, *not in the public example* marks fields or values that were added
|
||||
for a test case and do not come from the documented example.
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `messages_tool_use.json` | Message with one `tool_use` block, `stop_reason: tool_use`, `usage` with cache fields. | The tool name and input. |
|
||||
| `messages_text.json` | Message with `text` blocks, `stop_reason: end_turn`. | A second text block, to test joining. |
|
||||
| `messages_max_tokens.json` | Message with a `text` block, `stop_reason: max_tokens`. | — |
|
||||
| `models_page1.json` | Models list page: `data`, `has_more: true`, `first_id`, `last_id`. | — |
|
||||
| `models_page2.json` | Last models list page, `has_more: false`. | The second model has no `display_name`, to test the fallback to `id`. |
|
||||
| `error_authentication.json` | Error, `authentication_error` (401). | The `{{KEY}}` text; added to test redaction. |
|
||||
| `error_permission.json` | Error, `permission_error` (403). | — |
|
||||
| `error_rate_limit.json` | Error, `rate_limit_error` (429). | The `{{KEY}}` text; added to test redaction. |
|
||||
| `error_overloaded.json` | Error, `overloaded_error` (529). | — |
|
||||
| `error_not_found.json` | Error, `not_found_error` (404). | — |
|
||||
| `error_invalid_request.json` | Error, `invalid_request_error` (400). | — |
|
||||
| `error_api.json` | Error, `api_error` (500). | The `x-api-key: {{KEY}}` text; added to test redaction. |
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "api_error", "message": "An unexpected error has occurred internal to Anthropic's systems. x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G4"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit for key {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G0"}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "Here are some Side quests for Our kitchen"}
|
||||
],
|
||||
"stop_reason": "max_tokens",
|
||||
"stop_sequence": null,
|
||||
"usage": {"input_tokens": 472, "output_tokens": 400}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "Try a crock pot chili: "},
|
||||
{"type": "text", "text": "it is a Quick dinner."}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 2095,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 503
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "msg_01Aq9w938a90dw8q",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01A09q90qw90lq917835lq9",
|
||||
"name": "side_quests",
|
||||
"input": {"dinners": [{"name": "Sheet-pan gnocchi", "why": "Like your Regular tray bake, but quicker."}]}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 472,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 61
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-sonnet-5",
|
||||
"display_name": "Claude Sonnet 5",
|
||||
"created_at": "2026-02-24T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"first_id": "claude-sonnet-5",
|
||||
"last_id": "claude-sonnet-5"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"display_name": "Claude Haiku 4.5",
|
||||
"created_at": "2025-10-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-opus-5-5",
|
||||
"created_at": "2026-05-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"first_id": "claude-haiku-4-5-20251001",
|
||||
"last_id": "claude-opus-5-5"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuth means the vendor rejected the credentials.
|
||||
ErrAuth = errors.New("llm: authentication failed")
|
||||
// ErrRateLimited means the vendor throttled the request. The concrete
|
||||
// error is a *RateLimitedError carrying the requested wait.
|
||||
ErrRateLimited = errors.New("llm: rate limited")
|
||||
// ErrTimeout means the request did not finish within its timeout.
|
||||
ErrTimeout = errors.New("llm: request timed out")
|
||||
// ErrBadOutput means the model replied but the reply is unusable, e.g.
|
||||
// structured output that does not parse.
|
||||
ErrBadOutput = errors.New("llm: unusable model output")
|
||||
// ErrProvider means the vendor returned an unexpected HTTP status. The
|
||||
// concrete error is a *ProviderError carrying the status and a redacted
|
||||
// body excerpt.
|
||||
ErrProvider = errors.New("llm: provider error")
|
||||
)
|
||||
|
||||
// RateLimitedError is a throttled request. It matches ErrRateLimited.
|
||||
// Adapters build it with NewRateLimitedError so its text never holds their
|
||||
// credential.
|
||||
type RateLimitedError struct {
|
||||
// RetryAfter is how long the vendor asked callers to wait; 0 means it
|
||||
// did not say.
|
||||
RetryAfter time.Duration
|
||||
// text is the redacted Error text set by NewRateLimitedError; "" means
|
||||
// the error was built without a credential.
|
||||
text string
|
||||
}
|
||||
|
||||
// NewRateLimitedError returns the error for a throttled request. secret is
|
||||
// the credential sent with the request, or "" when the request sent none.
|
||||
// Every occurrence of a non-empty secret is removed from the error text
|
||||
// when it is built, even where the fixed text happens to spell it, and the
|
||||
// secret itself is not kept.
|
||||
func NewRateLimitedError(retryAfter time.Duration, secret string) *RateLimitedError {
|
||||
e := &RateLimitedError{RetryAfter: retryAfter}
|
||||
e.text = Redact(e.Error(), secret)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *RateLimitedError) Error() string {
|
||||
if e.text != "" {
|
||||
return e.text
|
||||
}
|
||||
if e.RetryAfter == 0 {
|
||||
return ErrRateLimited.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: retry after %v", ErrRateLimited, e.RetryAfter)
|
||||
}
|
||||
|
||||
// Is reports whether target is ErrRateLimited.
|
||||
func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimited }
|
||||
|
||||
// MaxBodyExcerpt is the longest response-body excerpt, in bytes, a
|
||||
// ProviderError keeps.
|
||||
const MaxBodyExcerpt = 512
|
||||
|
||||
// redactionMarker replaces each secret in redacted text, unless the marker
|
||||
// would itself hold the secret.
|
||||
const redactionMarker = "[REDACTED]"
|
||||
|
||||
// Redact returns s with every occurrence of a non-empty secret removed. Each
|
||||
// occurrence becomes "[REDACTED]" when the result then holds no secret;
|
||||
// otherwise, because the marker contains the secret or joins with its
|
||||
// neighbours to form it, occurrences are deleted until none remains. The
|
||||
// result never contains secret.
|
||||
func Redact(s, secret string) string {
|
||||
if secret == "" {
|
||||
return s
|
||||
}
|
||||
if out := strings.ReplaceAll(s, secret, redactionMarker); !strings.Contains(out, secret) {
|
||||
return out
|
||||
}
|
||||
for strings.Contains(s, secret) {
|
||||
s = strings.ReplaceAll(s, secret, "")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ProviderError is an unexpected HTTP status from the vendor. It matches
|
||||
// ErrProvider. Build it with NewProviderError, which redacts the body.
|
||||
type ProviderError struct {
|
||||
status int
|
||||
body string
|
||||
text string
|
||||
}
|
||||
|
||||
// NewProviderError returns the error for an unexpected HTTP status. It
|
||||
// always keeps a body excerpt of at most MaxBodyExcerpt bytes. secret is the
|
||||
// credential sent with the request, or "" when the request sent none; every
|
||||
// occurrence of a non-empty secret in body is removed by Redact before the
|
||||
// body is cut, so no part of it survives, and again after the cut, so the
|
||||
// truncation mark cannot complete it. It is removed from the complete error
|
||||
// text too, even where the fixed text happens to spell it, and the secret
|
||||
// itself is not kept.
|
||||
func NewProviderError(status int, body []byte, secret string) *ProviderError {
|
||||
s := strings.TrimSpace(Redact(string(body), secret))
|
||||
if len(s) > MaxBodyExcerpt {
|
||||
cut := MaxBodyExcerpt
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
s = Redact(s[:cut]+"…", secret)
|
||||
}
|
||||
text := fmt.Sprintf("%s: HTTP %d", ErrProvider, status)
|
||||
if s != "" {
|
||||
text += ": " + s
|
||||
}
|
||||
return &ProviderError{status: status, body: s, text: Redact(text, secret)}
|
||||
}
|
||||
|
||||
// Status returns the HTTP status code.
|
||||
func (e *ProviderError) Status() int { return e.status }
|
||||
|
||||
// Body returns the redacted, truncated response-body excerpt; it is empty
|
||||
// when the vendor sent no body.
|
||||
func (e *ProviderError) Body() string { return e.body }
|
||||
|
||||
func (e *ProviderError) Error() string { return e.text }
|
||||
|
||||
// Is reports whether target is ErrProvider.
|
||||
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|
||||
@@ -0,0 +1,198 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSentinelErrorsMatchWhenWrapped(t *testing.T) {
|
||||
for _, sentinel := range []error{ErrAuth, ErrTimeout, ErrBadOutput} {
|
||||
wrapped := fmt.Errorf("openai: generate: %w", sentinel)
|
||||
if !errors.Is(wrapped, sentinel) {
|
||||
t.Errorf("errors.Is(%v, %v) = false", wrapped, sentinel)
|
||||
}
|
||||
for _, other := range []error{ErrAuth, ErrTimeout, ErrBadOutput, ErrRateLimited, ErrProvider} {
|
||||
if other != sentinel && errors.Is(wrapped, other) {
|
||||
t.Errorf("errors.Is(%v, %v) = true", wrapped, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitedError(t *testing.T) {
|
||||
err := fmt.Errorf("anthropic: %w", &RateLimitedError{RetryAfter: 30 * time.Second})
|
||||
if !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatal("errors.Is(err, ErrRateLimited) = false")
|
||||
}
|
||||
if errors.Is(err, ErrProvider) || errors.Is(err, ErrAuth) {
|
||||
t.Fatal("rate-limited error matches an unrelated sentinel")
|
||||
}
|
||||
var rl *RateLimitedError
|
||||
if !errors.As(err, &rl) {
|
||||
t.Fatal("errors.As(err, *RateLimitedError) = false")
|
||||
}
|
||||
if rl.RetryAfter != 30*time.Second {
|
||||
t.Fatalf("RetryAfter = %v, want 30s", rl.RetryAfter)
|
||||
}
|
||||
if got := err.Error(); got != "anthropic: llm: rate limited: retry after 30s" {
|
||||
t.Fatalf("Error() = %q", got)
|
||||
}
|
||||
if got := (&RateLimitedError{}).Error(); got != "llm: rate limited" {
|
||||
t.Fatalf("Error() without RetryAfter = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderError(t *testing.T) {
|
||||
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `), "sk-test"))
|
||||
if !errors.Is(err, ErrProvider) {
|
||||
t.Fatal("errors.Is(err, ErrProvider) = false")
|
||||
}
|
||||
if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrAuth) {
|
||||
t.Fatal("provider error matches an unrelated sentinel")
|
||||
}
|
||||
var pe *ProviderError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatal("errors.As(err, *ProviderError) = false")
|
||||
}
|
||||
if pe.Status() != 503 || pe.Body() != `{"error":"overloaded"}` {
|
||||
t.Fatalf("Status, Body = %d, %q", pe.Status(), pe.Body())
|
||||
}
|
||||
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
||||
t.Fatalf("Error() = %q", got)
|
||||
}
|
||||
if got := NewProviderError(500, nil, "sk-test").Error(); got != "llm: provider error: HTTP 500" {
|
||||
t.Fatalf("Error() without body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
||||
const key = "sk-live-0123456789abcdef"
|
||||
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
||||
pe := NewProviderError(400, body, key)
|
||||
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||
if strings.Contains(s, key) {
|
||||
t.Fatalf("secret leaked: %q", s)
|
||||
}
|
||||
}
|
||||
want := `{"error":"invalid key [REDACTED]","echo":"Bearer [REDACTED]"}`
|
||||
if pe.Body() != want {
|
||||
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
||||
const key = "sk-secret-straddling-the-cut"
|
||||
// The key starts just before the excerpt limit, so truncating first
|
||||
// would leave its prefix in the excerpt.
|
||||
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
||||
pe := NewProviderError(500, []byte(body), key)
|
||||
if strings.Contains(pe.Body(), "sk-se") {
|
||||
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
||||
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
||||
pe := NewProviderError(500, []byte(body), "sk-test")
|
||||
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
||||
if pe.Body() != want {
|
||||
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorKeepsBoundedBodyWithoutSecret(t *testing.T) {
|
||||
body := []byte(` {"error":"model \"llama9\" not found"} `)
|
||||
pe := NewProviderError(404, body, "")
|
||||
if pe.Body() != `{"error":"model \"llama9\" not found"}` {
|
||||
t.Fatalf("Body() = %q", pe.Body())
|
||||
}
|
||||
if got := pe.Error(); got != `llm: provider error: HTTP 404: {"error":"model \"llama9\" not found"}` {
|
||||
t.Fatalf("Error() = %q", got)
|
||||
}
|
||||
long := NewProviderError(500, []byte(strings.Repeat("x", 4*MaxBodyExcerpt)), "")
|
||||
if want := strings.Repeat("x", MaxBodyExcerpt) + "…"; long.Body() != want {
|
||||
t.Fatalf("Body() has %d bytes, want %d", len(long.Body()), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactNeverLeavesSecret(t *testing.T) {
|
||||
cases := []struct{ s, secret, want string }{
|
||||
{"key sk-1 and Bearer sk-1", "sk-1", "key [REDACTED] and Bearer [REDACTED]"},
|
||||
{"no secret here", "sk-1", "no secret here"},
|
||||
{"x", "", "x"},
|
||||
// The marker holds the secret, so occurrences are deleted.
|
||||
{"rejected REDACTED", "REDACTED", "rejected "},
|
||||
{"A then A", "A", " then "},
|
||||
// The marker joins with a neighbour to form the secret.
|
||||
{"]]aa", "]a", ""},
|
||||
// The marker holds the secret and deleting once leaves a new one.
|
||||
{"DDAA", "DA", ""},
|
||||
{"aabb", "ab", "a[REDACTED]b"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Redact(c.s, c.secret); got != c.want {
|
||||
t.Errorf("Redact(%q, %q) = %q, want %q", c.s, c.secret, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorWithMarkerSecretNeverLeaksSecret(t *testing.T) {
|
||||
const key = "REDACTED"
|
||||
pe := NewProviderError(500, []byte(`{"error":"bad key REDACTED","echo":"Bearer REDACTED"}`), key)
|
||||
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||
if strings.Contains(s, key) {
|
||||
t.Fatalf("secret leaked: %q", s)
|
||||
}
|
||||
}
|
||||
if want := `{"error":"bad key ","echo":"Bearer "}`; pe.Body() != want {
|
||||
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorTruncationMarkCannotCompleteSecret(t *testing.T) {
|
||||
const key = "z…"
|
||||
body := strings.Repeat("a", MaxBodyExcerpt-1) + "z" + strings.Repeat("b", 10)
|
||||
if pe := NewProviderError(500, []byte(body), key); strings.Contains(pe.Body(), key) {
|
||||
t.Fatalf("secret leaked: %q", pe.Body())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||
const secret = "HTTP"
|
||||
pe := NewProviderError(500, []byte(`{"error":"HTTP down"}`), secret)
|
||||
if strings.Contains(pe.Error(), secret) || strings.Contains(pe.Body(), secret) {
|
||||
t.Fatalf("secret leaked: Error %q, Body %q", pe.Error(), pe.Body())
|
||||
}
|
||||
if got := NewProviderError(500, nil, secret).Error(); got != "llm: provider error: [REDACTED] 500" {
|
||||
t.Fatalf("Error() without body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitedErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||
for _, secret := range []string{"rate", "retry after"} {
|
||||
for _, wait := range []time.Duration{0, 30 * time.Second} {
|
||||
rl := NewRateLimitedError(wait, secret)
|
||||
if strings.Contains(rl.Error(), secret) {
|
||||
t.Fatalf("secret %q leaked: %q", secret, rl.Error())
|
||||
}
|
||||
if rl.RetryAfter != wait || !errors.Is(rl, ErrRateLimited) {
|
||||
t.Fatalf("NewRateLimitedError(%v) = %+v", wait, rl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedErrorsDoNotKeepSecret(t *testing.T) {
|
||||
const secret = "sk-live-0123456789abcdef"
|
||||
for _, err := range []error{
|
||||
NewProviderError(500, []byte("down "+secret), secret),
|
||||
NewRateLimitedError(30*time.Second, secret),
|
||||
} {
|
||||
if got := fmt.Sprintf("%#v %+v", err, err); strings.Contains(got, secret) {
|
||||
t.Fatalf("formatting %T leaks the secret: %q", err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Fake is a scripted Provider for tests. Each method returns its queued
|
||||
// results in order, one per call, and records every Request Generate
|
||||
// receives. A call with nothing left in its queue fails, so an unexpected
|
||||
// call is never silently answered. It is safe for concurrent use.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
generate []GenerateResult
|
||||
models []ListModelsResult
|
||||
verify []error
|
||||
requests []Request
|
||||
}
|
||||
|
||||
var _ Provider = (*Fake)(nil)
|
||||
|
||||
// GenerateResult is one scripted Generate outcome.
|
||||
type GenerateResult struct {
|
||||
Response Response
|
||||
Err error
|
||||
}
|
||||
|
||||
// ListModelsResult is one scripted ListModels outcome.
|
||||
type ListModelsResult struct {
|
||||
Models []Model
|
||||
Err error
|
||||
}
|
||||
|
||||
// QueueGenerate appends results to the Generate script.
|
||||
func (f *Fake) QueueGenerate(results ...GenerateResult) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.generate = append(f.generate, results...)
|
||||
}
|
||||
|
||||
// QueueListModels appends results to the ListModels script.
|
||||
func (f *Fake) QueueListModels(results ...ListModelsResult) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.models = append(f.models, results...)
|
||||
}
|
||||
|
||||
// QueueVerify appends results to the Verify script; a nil entry is success.
|
||||
func (f *Fake) QueueVerify(results ...error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.verify = append(f.verify, results...)
|
||||
}
|
||||
|
||||
// Generate records req and returns the next scripted result.
|
||||
func (f *Fake) Generate(_ context.Context, req Request) (Response, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requests = append(f.requests, cloneRequest(req))
|
||||
if len(f.generate) == 0 {
|
||||
return Response{}, fmt.Errorf("llm: fake: unscripted Generate call %d", len(f.requests))
|
||||
}
|
||||
r := f.generate[0]
|
||||
f.generate = f.generate[1:]
|
||||
return r.Response, r.Err
|
||||
}
|
||||
|
||||
// ListModels returns the next scripted result.
|
||||
func (f *Fake) ListModels(context.Context) ([]Model, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.models) == 0 {
|
||||
return nil, fmt.Errorf("llm: fake: unscripted ListModels call")
|
||||
}
|
||||
r := f.models[0]
|
||||
f.models = f.models[1:]
|
||||
return slices.Clone(r.Models), r.Err
|
||||
}
|
||||
|
||||
// Verify returns the next scripted result.
|
||||
func (f *Fake) Verify(context.Context) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.verify) == 0 {
|
||||
return fmt.Errorf("llm: fake: unscripted Verify call")
|
||||
}
|
||||
err := f.verify[0]
|
||||
f.verify = f.verify[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
// Requests returns a copy of every Request Generate received, in order.
|
||||
func (f *Fake) Requests() []Request {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]Request, len(f.requests))
|
||||
for i, r := range f.requests {
|
||||
out[i] = cloneRequest(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Remaining reports how many scripted results of each kind are unused, so a
|
||||
// test can assert its script was consumed.
|
||||
func (f *Fake) Remaining() (generate, listModels, verify int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.generate), len(f.models), len(f.verify)
|
||||
}
|
||||
|
||||
// cloneRequest deep-copies r so a recorded Request is unaffected by later
|
||||
// changes the caller makes to its slices and pointers.
|
||||
func cloneRequest(r Request) Request {
|
||||
r.Messages = slices.Clone(r.Messages)
|
||||
if r.Temperature != nil {
|
||||
t := *r.Temperature
|
||||
r.Temperature = &t
|
||||
}
|
||||
if r.Schema != nil {
|
||||
s := JSONSchema{Name: r.Schema.Name, Schema: json.RawMessage(slices.Clone([]byte(r.Schema.Schema)))}
|
||||
r.Schema = &s
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFakeGenerateReturnsScriptInOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var f Fake
|
||||
f.QueueGenerate(
|
||||
GenerateResult{Response: Response{Text: "first", Model: "m1"}},
|
||||
GenerateResult{Err: &RateLimitedError{RetryAfter: time.Second}},
|
||||
)
|
||||
f.QueueGenerate(GenerateResult{Response: Response{JSON: json.RawMessage(`{"ok":true}`), FinishReason: "stop"}})
|
||||
|
||||
req := Request{Messages: []Message{{Role: RoleUser, Content: "one"}}}
|
||||
resp, err := f.Generate(ctx, req)
|
||||
if err != nil || resp.Text != "first" || resp.Model != "m1" {
|
||||
t.Fatalf("call 1 = %+v, %v", resp, err)
|
||||
}
|
||||
if _, err := f.Generate(ctx, req); !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("call 2 err = %v, want ErrRateLimited", err)
|
||||
}
|
||||
resp, err = f.Generate(ctx, req)
|
||||
if err != nil || string(resp.JSON) != `{"ok":true}` || resp.FinishReason != "stop" {
|
||||
t.Fatalf("call 3 = %+v, %v", resp, err)
|
||||
}
|
||||
if _, err := f.Generate(ctx, req); err == nil {
|
||||
t.Fatal("unscripted call 4 succeeded")
|
||||
}
|
||||
if g, l, v := f.Remaining(); g != 0 || l != 0 || v != 0 {
|
||||
t.Fatalf("Remaining() = %d, %d, %d", g, l, v)
|
||||
}
|
||||
if n := len(f.Requests()); n != 4 {
|
||||
t.Fatalf("recorded %d requests, want 4 (unscripted calls are recorded too)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRecordsRequests(t *testing.T) {
|
||||
var f Fake
|
||||
f.QueueGenerate(GenerateResult{}, GenerateResult{})
|
||||
temp := 0.2
|
||||
first := Request{
|
||||
System: "be brief",
|
||||
Messages: []Message{{Role: RoleUser, Content: "plan dinners"}},
|
||||
Temperature: &temp,
|
||||
MaxTokens: 100,
|
||||
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
if _, err := f.Generate(context.Background(), first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Generate(context.Background(), Request{System: "second"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Later changes by the caller must not alter what was recorded.
|
||||
first.Messages[0].Content = "changed"
|
||||
temp = 1
|
||||
first.Schema.Schema[2] = 'X'
|
||||
|
||||
got := f.Requests()
|
||||
if len(got) != 2 || got[1].System != "second" {
|
||||
t.Fatalf("Requests() = %+v", got)
|
||||
}
|
||||
r := got[0]
|
||||
if r.System != "be brief" || r.Messages[0].Content != "plan dinners" || *r.Temperature != 0.2 ||
|
||||
r.MaxTokens != 100 || r.Schema.Name != "plan" || string(r.Schema.Schema) != `{"type":"object"}` ||
|
||||
r.Timeout != 5*time.Second {
|
||||
t.Fatalf("recorded request = %+v", r)
|
||||
}
|
||||
|
||||
// Changing a returned copy must not alter the record either.
|
||||
got[0].Messages[0].Content = "mutated"
|
||||
if f.Requests()[0].Messages[0].Content != "plan dinners" {
|
||||
t.Fatal("Requests() returned shared state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeListModelsAndVerify(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var f Fake
|
||||
f.QueueListModels(
|
||||
ListModelsResult{Models: []Model{{ID: "a", DisplayName: "A"}, {ID: "b", DisplayName: "b"}}},
|
||||
ListModelsResult{Err: ErrAuth},
|
||||
)
|
||||
f.QueueVerify(nil, fmt.Errorf("wrapped: %w", ErrAuth))
|
||||
|
||||
models, err := f.ListModels(ctx)
|
||||
if err != nil || len(models) != 2 || models[0].ID != "a" {
|
||||
t.Fatalf("ListModels 1 = %+v, %v", models, err)
|
||||
}
|
||||
if _, err := f.ListModels(ctx); !errors.Is(err, ErrAuth) {
|
||||
t.Fatalf("ListModels 2 err = %v, want ErrAuth", err)
|
||||
}
|
||||
if _, err := f.ListModels(ctx); err == nil {
|
||||
t.Fatal("unscripted ListModels succeeded")
|
||||
}
|
||||
if err := f.Verify(ctx); err != nil {
|
||||
t.Fatalf("Verify 1 = %v", err)
|
||||
}
|
||||
if err := f.Verify(ctx); !errors.Is(err, ErrAuth) {
|
||||
t.Fatalf("Verify 2 = %v, want ErrAuth", err)
|
||||
}
|
||||
if err := f.Verify(ctx); err == nil {
|
||||
t.Fatal("unscripted Verify succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeConcurrentUse(t *testing.T) {
|
||||
const n = 50
|
||||
var f Fake
|
||||
for i := range n {
|
||||
f.QueueGenerate(GenerateResult{Response: Response{Text: fmt.Sprint(i)}})
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for range n {
|
||||
wg.Go(func() {
|
||||
if _, err := f.Generate(context.Background(), Request{}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if len(f.Requests()) != n {
|
||||
t.Fatalf("recorded %d requests, want %d", len(f.Requests()), n)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d Generate results unused", g)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package llm is the single interface MealQuest uses to talk to a language
|
||||
// model. Every vendor adapter implements Provider over plain net/http (no
|
||||
// vendor SDKs) and reports failures with this package's typed errors, so
|
||||
// callers decide what to do from errors.Is and errors.As alone.
|
||||
//
|
||||
// Errors from this package and its adapters never contain an API key.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Provider is a language-model vendor.
|
||||
type Provider interface {
|
||||
// Generate sends one request and returns the model's reply.
|
||||
Generate(ctx context.Context, req Request) (Response, error)
|
||||
// ListModels returns the models the configured credentials can use.
|
||||
ListModels(ctx context.Context) ([]Model, error)
|
||||
// Verify checks that the configured credentials are accepted.
|
||||
Verify(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Role is the author of a Message.
|
||||
type Role string
|
||||
|
||||
// The roles a Message may have. The system prompt is Request.System, not a
|
||||
// message.
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
)
|
||||
|
||||
// Message is one turn of a conversation.
|
||||
type Message struct {
|
||||
Role Role
|
||||
Content string
|
||||
}
|
||||
|
||||
// JSONSchema is a named structured-output contract: when a Request carries
|
||||
// one, the model must reply with JSON matching Schema.
|
||||
type JSONSchema struct {
|
||||
// Name identifies the contract to the vendor, e.g. "side_quests".
|
||||
Name string
|
||||
// Schema is a JSON Schema object.
|
||||
Schema json.RawMessage
|
||||
}
|
||||
|
||||
// Request is one generation request.
|
||||
type Request struct {
|
||||
// System is the system prompt; empty means none.
|
||||
System string
|
||||
// Messages is the conversation, oldest first. It must not be empty.
|
||||
Messages []Message
|
||||
// Temperature is the sampling temperature; nil means the vendor default.
|
||||
Temperature *float64
|
||||
// MaxTokens caps the reply length; 0 means the vendor default.
|
||||
MaxTokens int
|
||||
// Schema, when set, requests structured output; the reply is then in
|
||||
// Response.JSON.
|
||||
Schema *JSONSchema
|
||||
// Timeout bounds the request; 0 means only ctx bounds it.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether r is well formed. Adapters call it before
|
||||
// contacting the vendor. Its errors never quote the request's text, such as
|
||||
// a role or schema name: any of it may equal the API key.
|
||||
func (r Request) Validate() error {
|
||||
if len(r.Messages) == 0 {
|
||||
return errors.New("llm: request has no messages")
|
||||
}
|
||||
for i, m := range r.Messages {
|
||||
if m.Role != RoleUser && m.Role != RoleAssistant {
|
||||
return fmt.Errorf("llm: message %d has an unknown role", i)
|
||||
}
|
||||
}
|
||||
if r.Temperature != nil && *r.Temperature < 0 {
|
||||
return fmt.Errorf("llm: temperature %v is negative", *r.Temperature)
|
||||
}
|
||||
if r.MaxTokens < 0 {
|
||||
return fmt.Errorf("llm: max tokens %d is negative", r.MaxTokens)
|
||||
}
|
||||
if r.Timeout < 0 {
|
||||
return fmt.Errorf("llm: timeout %v is negative", r.Timeout)
|
||||
}
|
||||
if r.Schema != nil {
|
||||
if r.Schema.Name == "" {
|
||||
return errors.New("llm: schema has no name")
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(r.Schema.Schema, &obj); err != nil || obj == nil {
|
||||
return errors.New("llm: schema is not a JSON object")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Usage is the token count of one Generate call.
|
||||
type Usage struct {
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
}
|
||||
|
||||
// Total returns InputTokens + OutputTokens.
|
||||
func (u Usage) Total() int { return u.InputTokens + u.OutputTokens }
|
||||
|
||||
// Response is the model's reply to a Request.
|
||||
type Response struct {
|
||||
// Text is the reply text.
|
||||
Text string
|
||||
// JSON is the structured reply when the Request carried a Schema, and
|
||||
// nil otherwise.
|
||||
JSON json.RawMessage
|
||||
// Usage is the tokens the call consumed.
|
||||
Usage Usage
|
||||
// Model is the model that answered, as the vendor reports it.
|
||||
Model string
|
||||
// FinishReason is why generation stopped, as the vendor reports it.
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
// Model is a model a Provider offers.
|
||||
type Model struct {
|
||||
// ID is the vendor's model identifier, used in requests.
|
||||
ID string
|
||||
// DisplayName is a human-readable name; it equals ID when the vendor
|
||||
// gives none.
|
||||
DisplayName string
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestValidate(t *testing.T) {
|
||||
neg := -0.1
|
||||
valid := func() Request {
|
||||
return Request{
|
||||
Messages: []Message{{Role: RoleUser, Content: "hi"}, {Role: RoleAssistant, Content: "hello"}},
|
||||
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||
}
|
||||
}
|
||||
if err := valid().Validate(); err != nil {
|
||||
t.Fatalf("valid request: %v", err)
|
||||
}
|
||||
cases := map[string]func(*Request){
|
||||
"no messages": func(r *Request) { r.Messages = nil },
|
||||
"unknown role": func(r *Request) { r.Messages[0].Role = "system" },
|
||||
"negative temp": func(r *Request) { r.Temperature = &neg },
|
||||
"negative max": func(r *Request) { r.MaxTokens = -1 },
|
||||
"negative time": func(r *Request) { r.Timeout = -1 },
|
||||
"unnamed schema": func(r *Request) { r.Schema.Name = "" },
|
||||
"schema array": func(r *Request) { r.Schema.Schema = json.RawMessage(`[]`) },
|
||||
"schema null": func(r *Request) { r.Schema.Schema = json.RawMessage(`null`) },
|
||||
"schema invalid": func(r *Request) { r.Schema.Schema = json.RawMessage(`{`) },
|
||||
"schema empty": func(r *Request) { r.Schema.Schema = nil },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
r := valid()
|
||||
mutate(&r)
|
||||
if err := r.Validate(); err == nil {
|
||||
t.Errorf("%s: Validate() = nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageTotal(t *testing.T) {
|
||||
if got := (Usage{InputTokens: 12, OutputTokens: 30}).Total(); got != 42 {
|
||||
t.Fatalf("Total() = %d, want 42", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
// Package openai is the llm.Provider adapter for OpenAI-compatible chat
|
||||
// APIs: OpenAI itself, OpenRouter and Ollama's /v1 endpoint. It calls
|
||||
// POST {base}/chat/completions and GET {base}/models over plain net/http.
|
||||
//
|
||||
// The adapter never logs, and no error it returns contains the API key or
|
||||
// the Authorization header value: errors do not quote caller- or
|
||||
// vendor-supplied text, and every exported method passes its error through
|
||||
// redact as a backstop.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// StructuredOutput is how the adapter asks the vendor for JSON when a
|
||||
// Request carries a Schema. Which mode a vendor and model support comes
|
||||
// from its preset.
|
||||
type StructuredOutput string
|
||||
|
||||
const (
|
||||
// JSONSchema sends response_format {type: json_schema, strict: true}
|
||||
// with the Request's schema.
|
||||
JSONSchema StructuredOutput = "json_schema"
|
||||
// JSONObject sends response_format {type: json_object} and puts the
|
||||
// schema in the system prompt.
|
||||
JSONObject StructuredOutput = "json_object"
|
||||
// PromptOnly sends no response_format, puts the schema in the system
|
||||
// prompt and takes the first JSON object found in the reply.
|
||||
PromptOnly StructuredOutput = "prompt_only"
|
||||
)
|
||||
|
||||
// Config configures a Provider.
|
||||
type Config struct {
|
||||
// BaseURL is the API root the endpoint paths are appended to, e.g.
|
||||
// "https://api.openai.com/v1", "https://openrouter.ai/api/v1" or
|
||||
// "http://ollama:11434/v1".
|
||||
BaseURL string
|
||||
// APIKey is sent as "Authorization: Bearer <APIKey>". Empty sends no
|
||||
// Authorization header, for endpoints such as Ollama that need none.
|
||||
APIKey string
|
||||
// Model is the model Generate requests. ListModels and Verify do not
|
||||
// need it.
|
||||
Model string
|
||||
// Headers are extra request headers, e.g. OpenRouter's HTTP-Referer and
|
||||
// X-Title. An Accept header replaces the default "application/json".
|
||||
// Headers the adapter or net/http set themselves (see managedHeaders)
|
||||
// are rejected.
|
||||
Headers map[string]string
|
||||
// StructuredOutput is how JSON is requested when a Request has a Schema.
|
||||
StructuredOutput StructuredOutput
|
||||
}
|
||||
|
||||
// Provider is an OpenAI-compatible llm.Provider.
|
||||
type Provider struct {
|
||||
base string
|
||||
apiKey string
|
||||
model string
|
||||
headers http.Header
|
||||
mode StructuredOutput
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ llm.Provider = (*Provider)(nil)
|
||||
|
||||
// New validates cfg and returns a Provider.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
p, err := newFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, redact(err, cfg.APIKey)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func newFromConfig(cfg Config) (*Provider, error) {
|
||||
// Base URL errors never echo the URL: a rejected one may carry a
|
||||
// credential.
|
||||
u, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("openai: base URL does not parse")
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("openai: base URL must be an absolute http or https URL")
|
||||
}
|
||||
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||
// endpoint path appended to it would land in the query or fragment.
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("openai: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
// No error from New quotes caller-supplied text or a name derived from
|
||||
// it: any such text may equal or contain the API key, and redaction
|
||||
// cannot catch a fragment or a case-changed copy of it.
|
||||
switch cfg.StructuredOutput {
|
||||
case JSONSchema, JSONObject, PromptOnly:
|
||||
default:
|
||||
return nil, errors.New("openai: unknown structured output mode")
|
||||
}
|
||||
headers := make(http.Header, len(cfg.Headers)+1)
|
||||
for name, value := range cfg.Headers {
|
||||
if !validHeaderName(name) {
|
||||
return nil, errors.New("openai: an extra header name is not a valid HTTP token")
|
||||
}
|
||||
canonical := textproto.CanonicalMIMEHeaderKey(name)
|
||||
if managedHeaders[canonical] {
|
||||
return nil, errors.New("openai: an extra header is set by the adapter")
|
||||
}
|
||||
if !validHeaderValue(value) {
|
||||
return nil, errors.New("openai: an extra header value has a control character")
|
||||
}
|
||||
headers.Set(canonical, value)
|
||||
}
|
||||
if headers.Get("Accept") == "" {
|
||||
headers.Set("Accept", "application/json")
|
||||
}
|
||||
return &Provider{
|
||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
headers: headers,
|
||||
mode: cfg.StructuredOutput,
|
||||
client: &http.Client{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// managedHeaders are the request headers the adapter or net/http set; a
|
||||
// configured value for one would be overwritten or ignored.
|
||||
var managedHeaders = map[string]bool{
|
||||
"Authorization": true,
|
||||
"Content-Type": true,
|
||||
"Content-Length": true,
|
||||
"Transfer-Encoding": true,
|
||||
"Host": true,
|
||||
"Connection": true,
|
||||
}
|
||||
|
||||
// validHeaderName reports whether name is an RFC 9110 field-name: a
|
||||
// non-empty token of letters, digits and !#$%&'*+-.^_`|~.
|
||||
func validHeaderName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
switch {
|
||||
case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9':
|
||||
case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||
const maxResponseBody = 16 << 20
|
||||
|
||||
// maxErrorBody caps how much of an error response body is read. It is far
|
||||
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
|
||||
// reach the excerpt.
|
||||
const maxErrorBody = 64 << 10
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type jsonSchemaFormat struct {
|
||||
Name string `json:"name"`
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
Strict bool `json:"strict"`
|
||||
}
|
||||
|
||||
type responseFormat struct {
|
||||
Type string `json:"type"`
|
||||
JSONSchema *jsonSchemaFormat `json:"json_schema,omitempty"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
ResponseFormat *responseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content *string `json:"content"`
|
||||
Refusal *string `json:"refusal"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
|
||||
// schemaInstruction is appended to the system prompt in the json_object and
|
||||
// prompt_only modes, where the schema is not sent in response_format.
|
||||
const schemaInstruction = "Reply with only a JSON object, and no other text, that matches this JSON Schema:\n"
|
||||
|
||||
// Generate sends req to {base}/chat/completions.
|
||||
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
resp, err := p.generate(ctx, req)
|
||||
return resp, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("openai: generate: %w", err)
|
||||
}
|
||||
if p.model == "" {
|
||||
return llm.Response{}, errors.New("openai: generate: no model configured")
|
||||
}
|
||||
body := chatRequest{
|
||||
Model: p.model,
|
||||
Temperature: req.Temperature,
|
||||
MaxTokens: req.MaxTokens,
|
||||
}
|
||||
system := req.System
|
||||
if req.Schema != nil {
|
||||
switch p.mode {
|
||||
case JSONSchema:
|
||||
body.ResponseFormat = &responseFormat{
|
||||
Type: "json_schema",
|
||||
JSONSchema: &jsonSchemaFormat{Name: req.Schema.Name, Schema: req.Schema.Schema, Strict: true},
|
||||
}
|
||||
case JSONObject:
|
||||
body.ResponseFormat = &responseFormat{Type: "json_object"}
|
||||
system = appendInstruction(system, req.Schema.Schema)
|
||||
case PromptOnly:
|
||||
system = appendInstruction(system, req.Schema.Schema)
|
||||
}
|
||||
}
|
||||
if system != "" {
|
||||
body.Messages = append(body.Messages, chatMessage{Role: "system", Content: system})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
body.Messages = append(body.Messages, chatMessage{Role: string(m.Role), Content: m.Content})
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("openai: generate: encode request: %w", err)
|
||||
}
|
||||
|
||||
if req.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
raw, status, err := p.do(ctx, http.MethodPost, "/chat/completions", payload)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("openai: generate: %w", err)
|
||||
}
|
||||
|
||||
var cr chatResponse
|
||||
if err := json.Unmarshal(raw, &cr); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("openai: generate: decode response: %w", err)
|
||||
}
|
||||
if len(cr.Error) > 0 && string(cr.Error) != "null" {
|
||||
// OpenRouter can report an upstream failure in a 2xx body.
|
||||
return llm.Response{}, fmt.Errorf("openai: generate: %w", llm.NewProviderError(status, raw, p.apiKey))
|
||||
}
|
||||
if len(cr.Choices) == 0 {
|
||||
return llm.Response{}, errors.New("openai: generate: response has no choices")
|
||||
}
|
||||
choice := cr.Choices[0]
|
||||
resp := llm.Response{
|
||||
Usage: llm.Usage{InputTokens: cr.Usage.PromptTokens, OutputTokens: cr.Usage.CompletionTokens},
|
||||
Model: cr.Model,
|
||||
FinishReason: choice.FinishReason,
|
||||
}
|
||||
if choice.Message.Content != nil {
|
||||
resp.Text = *choice.Message.Content
|
||||
}
|
||||
if req.Schema == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if choice.Message.Refusal != nil && *choice.Message.Refusal != "" {
|
||||
return resp, fmt.Errorf("openai: generate: model refused the structured request: %w", llm.ErrBadOutput)
|
||||
}
|
||||
if p.mode == PromptOnly {
|
||||
resp.JSON = firstJSONObject(resp.Text)
|
||||
} else if text := strings.TrimSpace(resp.Text); isJSONObject(text) {
|
||||
resp.JSON = json.RawMessage(text)
|
||||
}
|
||||
if resp.JSON == nil {
|
||||
return resp, fmt.Errorf("openai: generate: reply holds no JSON object: %w", llm.ErrBadOutput)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func appendInstruction(system string, schema json.RawMessage) string {
|
||||
instruction := schemaInstruction + string(schema)
|
||||
if system == "" {
|
||||
return instruction
|
||||
}
|
||||
return system + "\n\n" + instruction
|
||||
}
|
||||
|
||||
// isJSONObject reports whether s is exactly one JSON object.
|
||||
func isJSONObject(s string) bool {
|
||||
return strings.HasPrefix(s, "{") && json.Valid([]byte(s))
|
||||
}
|
||||
|
||||
// firstJSONObject returns the first complete JSON object in s, trying each
|
||||
// "{" in order, or nil when there is none. Text around it, such as prose or
|
||||
// a Markdown code fence, is ignored.
|
||||
func firstJSONObject(s string) json.RawMessage {
|
||||
for i := strings.IndexByte(s, '{'); i >= 0; {
|
||||
var obj json.RawMessage
|
||||
if err := json.NewDecoder(strings.NewReader(s[i:])).Decode(&obj); err == nil {
|
||||
return obj
|
||||
}
|
||||
next := strings.IndexByte(s[i+1:], '{')
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
i += 1 + next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type modelsResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ListModels returns the models {base}/models lists.
|
||||
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models, err := p.listModels(ctx)
|
||||
return models, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
||||
raw, _, err := p.do(ctx, http.MethodGet, "/models", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai: list models: %w", err)
|
||||
}
|
||||
var mr modelsResponse
|
||||
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||
return nil, fmt.Errorf("openai: list models: decode response: %w", err)
|
||||
}
|
||||
if mr.Data == nil {
|
||||
return nil, errors.New("openai: list models: response has no data list")
|
||||
}
|
||||
models := make([]llm.Model, 0, len(mr.Data))
|
||||
for i, m := range mr.Data {
|
||||
if m.ID == "" {
|
||||
return nil, fmt.Errorf("openai: list models: model %d has no id", i)
|
||||
}
|
||||
name := m.Name
|
||||
if name == "" {
|
||||
name = m.ID
|
||||
}
|
||||
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// Verify checks the credentials with an authenticated ListModels call.
|
||||
func (p *Provider) Verify(ctx context.Context) error {
|
||||
_, err := p.listModels(ctx)
|
||||
return redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
// do sends one request and returns the body and status of a 2xx response.
|
||||
// Any other status becomes the matching llm typed error.
|
||||
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
if payload != nil {
|
||||
reqBody = bytes.NewReader(payload)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||
if err != nil {
|
||||
// The parse error names the URL, whose path may hold the API key.
|
||||
return nil, 0, errors.New("build request: request URL is invalid")
|
||||
}
|
||||
for name, values := range p.headers {
|
||||
httpReq.Header[name] = values
|
||||
}
|
||||
if payload != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if p.apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
|
||||
httpResp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, 0, p.transportError(err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||
if err != nil {
|
||||
return nil, httpResp.StatusCode, p.transportError(err)
|
||||
}
|
||||
return nil, httpResp.StatusCode, p.statusError(httpResp, body)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, httpResp.StatusCode, p.transportError(err)
|
||||
}
|
||||
if len(body) > maxResponseBody {
|
||||
return nil, httpResp.StatusCode, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||
}
|
||||
return body, httpResp.StatusCode, nil
|
||||
}
|
||||
|
||||
// statusError maps a non-2xx response to an llm typed error.
|
||||
func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
||||
case http.StatusTooManyRequests:
|
||||
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||
case http.StatusRequestTimeout:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
|
||||
default:
|
||||
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
|
||||
// form. A missing, malformed or past value means the vendor gave no usable
|
||||
// wait, reported as 0.
|
||||
func retryAfter(value string, now time.Time) time.Duration {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.Atoi(value); err == nil {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||
return at.Sub(now).Round(time.Second)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
}
|
||||
|
||||
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||
// with the request URL removed and the API key redacted, and unwraps only to
|
||||
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||
type transportFailure struct {
|
||||
detail string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string {
|
||||
if e.kind == llm.ErrTimeout {
|
||||
return fmt.Sprintf("%s: transport: %s", llm.ErrTimeout, e.detail)
|
||||
}
|
||||
return "transport: " + e.detail
|
||||
}
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
// transportError reports a failure to complete the HTTP exchange. A
|
||||
// *url.Error names the request URL, and a base URL path may hold the API
|
||||
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||
func (p *Provider) transportError(err error) error {
|
||||
var kind error
|
||||
switch {
|
||||
case isTimeout(err):
|
||||
kind = llm.ErrTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
kind = context.Canceled
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
return &transportFailure{detail: llm.Redact(err.Error(), p.apiKey), kind: kind}
|
||||
}
|
||||
|
||||
// redactedError stands in for one layer of an error chain that holds the
|
||||
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||
// returns the layer's causes passed through redact, so no error reached by
|
||||
// unwrapping has text holding the key. Is consults the original layer and
|
||||
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||
// remove the key from their own complete text, fixed words included, so
|
||||
// they always match; As refuses only a match from outside llm whose text
|
||||
// holds the key, so it never hands back an unredacted error.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
key string
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v from printing the fields, which hold the key.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
func (e *redactedError) As(target any) bool {
|
||||
if !errors.As(e.err, target) {
|
||||
return false
|
||||
}
|
||||
found := reflect.ValueOf(target).Elem()
|
||||
if matched, ok := found.Interface().(error); ok && holdsKey(matched, e.key) {
|
||||
found.SetZero()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redactedJoin is redactedError for a layer with several causes, such as
|
||||
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||
type redactedJoin struct {
|
||||
redactedError
|
||||
}
|
||||
|
||||
func (e *redactedJoin) Unwrap() []error {
|
||||
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||
safe := make([]error, len(causes))
|
||||
for i, c := range causes {
|
||||
safe[i] = redact(c, e.key)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// redact is the backstop every exported method applies to its error: when
|
||||
// the text of err or of any error in its chain holds a non-empty key, it
|
||||
// returns a stand-in whose text, and the text of every error it unwraps
|
||||
// to, has the key redacted. Any other err is returned unchanged.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" || !holdsKey(err, key) {
|
||||
return err
|
||||
}
|
||||
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
return &redactedJoin{re}
|
||||
}
|
||||
return &re
|
||||
}
|
||||
|
||||
// holdsKey reports whether the text of err or of any error in its chain
|
||||
// contains key.
|
||||
func holdsKey(err error, key string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), key) {
|
||||
return true
|
||||
}
|
||||
switch u := err.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
return holdsKey(u.Unwrap(), key)
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
if holdsKey(c, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
# OpenAI-compatible adapter fixtures
|
||||
|
||||
These are the vendor response bodies the contract tests in `openai_test.go`
|
||||
replay. They are **not live captures**. Product AI is tested with fakes, and
|
||||
live vendor calls are opt-in and never use credentials, so no request was
|
||||
sent to a vendor to make them. Each fixture is adapted from the response
|
||||
shape in that vendor's public API documentation or open source, then
|
||||
sanitized:
|
||||
|
||||
- IDs, timestamps, fingerprints and token counts are illustrative values in
|
||||
the documented format, not values from a real account.
|
||||
- Message content is replaced with MealQuest dinner text.
|
||||
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||
a vendor body.
|
||||
|
||||
The tests check the adapter against these bodies with independent
|
||||
assertions on both the request sent (path, headers, `response_format`,
|
||||
messages) and the response decoded. The request side does not depend on the
|
||||
fixtures.
|
||||
|
||||
Below, *not in the public example* marks fields or values that were added
|
||||
for a test case and do not come from the vendor's documented example.
|
||||
|
||||
## OpenAI
|
||||
|
||||
Sources: API reference, "The chat completion object" and "List models"
|
||||
(<https://platform.openai.com/docs/api-reference/chat/object>,
|
||||
<https://platform.openai.com/docs/api-reference/models/list>); the
|
||||
Structured Outputs guide, for `refusal`
|
||||
(<https://platform.openai.com/docs/guides/structured-outputs>); and the error
|
||||
codes guide (<https://platform.openai.com/docs/guides/error-codes>).
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `openai_chat_json_schema.json` | Chat completion object with `usage` details, `service_tier`, `system_fingerprint`. | The JSON reply content. |
|
||||
| `openai_chat_refusal.json` | Structured Outputs refusal: `content: null`, `refusal` set. | — |
|
||||
| `openai_models.json` | `{object: list, data: [model]}`. | — |
|
||||
| `openai_error_invalid_key.json` | Error object, `code: invalid_api_key`, message echoing the key. | The full key in the message; OpenAI masks most of it. Kept whole to test redaction. |
|
||||
| `openai_error_server.json` | Error object, `type: server_error`. | The `Authorization: Bearer {{KEY}}` text; added to test redaction. |
|
||||
|
||||
## OpenRouter
|
||||
|
||||
Sources: API reference, chat completion response and errors
|
||||
(<https://openrouter.ai/docs/api-reference/overview>,
|
||||
<https://openrouter.ai/docs/api-reference/errors>), and "List available
|
||||
models" (<https://openrouter.ai/docs/api-reference/list-available-models>).
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `openrouter_chat_json_object.json` | Chat completion with `provider`, `native_finish_reason` and `message.reasoning`. | The JSON reply content. |
|
||||
| `openrouter_chat_upstream_error.json` | An `error` object (`code`, `message`, `metadata.provider_name`, `metadata.raw`) in an HTTP 200 body, which OpenRouter documents for upstream failures. | `user_id`. |
|
||||
| `openrouter_error_rate_limited.json` | Error object, `code: 429`, rate-limit `metadata`. | The `Key {{KEY}}` text; added to test redaction. |
|
||||
| `openrouter_models.json` | `{data: [model]}` with `name`, `context_length`, `architecture`, `pricing`, `supported_parameters`. | The second model is a minimal entry, to test optional fields. |
|
||||
|
||||
## Ollama
|
||||
|
||||
Ollama's OpenAI compatibility page
|
||||
(<https://github.com/ollama/ollama/blob/main/docs/openai.md>) documents
|
||||
requests but shows no response bodies. The shapes come from its `openai`
|
||||
middleware source (<https://github.com/ollama/ollama/blob/main/openai/openai.go>):
|
||||
`chatcmpl-<n>` IDs, `system_fingerprint: fp_ollama`, `owned_by: library` on
|
||||
models, and errors as `{error: {message, type: api_error, param, code}}`.
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `ollama_chat_text.json` | Plain chat completion. | — |
|
||||
| `ollama_chat_prompt_only.json` | Chat completion. | The reply text: prose and stray braces around a fenced JSON object, to test prompt-only extraction. |
|
||||
| `ollama_chat_truncated.json` | Chat completion with `finish_reason: length`. | The cut-off JSON reply. |
|
||||
| `ollama_models.json` | `{object: list, data: [model]}`. | — |
|
||||
| `ollama_error_not_found.json` | Middleware error object for a missing model. | — |
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "chatcmpl-417",
|
||||
"object": "chat.completion",
|
||||
"created": 1741570322,
|
||||
"model": "llama3.2",
|
||||
"system_fingerprint": "fp_ollama",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Sure! Here is a dinner {idea} for you:\n```json\n{\"dinners\": [{\"name\": \"Crock pot chili\", \"why\": \"A Quick dinner like your {Regular} stew.\"}]}\n```\nEnjoy {\"not\": \"this one\"}!"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 512, "completion_tokens": 58, "total_tokens": 570}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "chatcmpl-419",
|
||||
"object": "chat.completion",
|
||||
"created": 1741570340,
|
||||
"model": "llama3.2",
|
||||
"system_fingerprint": "fp_ollama",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": "Try a lemon orzo bake tonight."}, "finish_reason": "stop"}
|
||||
],
|
||||
"usage": {"prompt_tokens": 40, "completion_tokens": 9, "total_tokens": 49}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "chatcmpl-418",
|
||||
"object": "chat.completion",
|
||||
"created": 1741570330,
|
||||
"model": "llama3.2",
|
||||
"system_fingerprint": "fp_ollama",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "{\"dinners\": [{\"name\": \"Crock pot ch"},
|
||||
"finish_reason": "length"
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 512, "completion_tokens": 16, "total_tokens": 528}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error": {"message": "model \"llama9\" not found, try pulling it first", "type": "api_error", "param": null, "code": null}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "llama3.2:latest", "object": "model", "created": 1741000000, "owned_by": "library"},
|
||||
{"id": "qwen2.5:7b", "object": "model", "created": 1740000000, "owned_by": "library"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeG",
|
||||
"object": "chat.completion",
|
||||
"created": 1741570283,
|
||||
"model": "gpt-4o-2024-08-06",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{\"dinners\":[{\"name\":\"Sheet-pan gnocchi\",\"why\":\"Like your Regular tray bake, but quicker.\"}]}",
|
||||
"refusal": null,
|
||||
"annotations": []
|
||||
},
|
||||
"logprobs": null,
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1117,
|
||||
"completion_tokens": 46,
|
||||
"total_tokens": 1163,
|
||||
"prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
|
||||
"completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
|
||||
},
|
||||
"service_tier": "default",
|
||||
"system_fingerprint": "fp_fc9f1d7035"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeH",
|
||||
"object": "chat.completion",
|
||||
"created": 1741570290,
|
||||
"model": "gpt-4o-2024-08-06",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": null, "refusal": "I'm sorry, I can't help with that request.", "annotations": []},
|
||||
"logprobs": null,
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 210, "completion_tokens": 11, "total_tokens": 221},
|
||||
"system_fingerprint": "fp_fc9f1d7035"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"error": {
|
||||
"message": "Incorrect API key provided: {{KEY}}. You can find your API key at https://platform.openai.com/account/api-keys.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error": {"message": "The server had an error while processing your request. Authorization: Bearer {{KEY}}", "type": "server_error", "param": null, "code": null}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "gpt-4o-2024-08-06", "object": "model", "created": 1722814719, "owned_by": "system"},
|
||||
{"id": "gpt-4o-mini", "object": "model", "created": 1721172741, "owned_by": "system"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "gen-1741570300-xQmT9vWb4kR2nP7sL3aE",
|
||||
"provider": "OpenAI",
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"object": "chat.completion",
|
||||
"created": 1741570300,
|
||||
"choices": [
|
||||
{
|
||||
"logprobs": null,
|
||||
"finish_reason": "stop",
|
||||
"native_finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{\n \"dinners\": [{\"name\": \"Chicken tinga tacos\", \"why\": \"Close to your taco-night Regular.\"}]\n}",
|
||||
"refusal": null,
|
||||
"reasoning": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 842, "completion_tokens": 31, "total_tokens": 873}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "gen-1741570311-aB3dE5fG7hJ9kL1mN2pQ",
|
||||
"error": {"code": 502, "message": "Upstream error from provider", "metadata": {"provider_name": "Lambda", "raw": "upstream returned 502"}},
|
||||
"user_id": "user_2abcDEF"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error": {"code": 429, "message": "Rate limit exceeded: free-models-per-min. Key {{KEY}}", "metadata": {"headers": {"X-RateLimit-Limit": "20", "X-RateLimit-Remaining": "0"}}}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "openai/gpt-4o-mini",
|
||||
"canonical_slug": "openai/gpt-4o-mini",
|
||||
"name": "OpenAI: GPT-4o-mini",
|
||||
"created": 1721260800,
|
||||
"description": "GPT-4o mini is OpenAI's newest model after GPT-4 Omni.",
|
||||
"context_length": 128000,
|
||||
"architecture": {"modality": "text+image->text", "input_modalities": ["text", "image"], "output_modalities": ["text"], "tokenizer": "GPT"},
|
||||
"pricing": {"prompt": "0.00000015", "completion": "0.0000006"},
|
||||
"supported_parameters": ["max_tokens", "temperature", "response_format", "structured_outputs"]
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet-5",
|
||||
"name": "Anthropic: Claude Sonnet 5",
|
||||
"created": 1767225600,
|
||||
"context_length": 200000,
|
||||
"supported_parameters": ["max_tokens", "temperature"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#5a358c" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "MealQuest",
|
||||
"short_name": "MealQuest",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#5a358c",
|
||||
"background_color": "#f9f6f1",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+2
-5245
File diff suppressed because it is too large
Load Diff
+3
-7
@@ -5,16 +5,13 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
|
||||
"test": "npm run lint:colors && vitest run",
|
||||
"lint:colors": "node scripts/lint-colors.mjs",
|
||||
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"lucide-react": "^1.51.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^7.18.4"
|
||||
@@ -29,7 +26,6 @@
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.2",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 505 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB |
@@ -1,32 +0,0 @@
|
||||
// Fails when the production build references Google Fonts: fonts are
|
||||
// self-hosted, so dist must never point at fonts.googleapis.com or
|
||||
// fonts.gstatic.com.
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const distDir = fileURLToPath(new URL('../dist', import.meta.url))
|
||||
const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com']
|
||||
|
||||
function* files(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) yield* files(path)
|
||||
else yield path
|
||||
}
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const path of files(distDir)) {
|
||||
const content = readFileSync(path, 'latin1')
|
||||
for (const host of hosts) {
|
||||
if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Production build references Google Fonts; fonts must be self-hosted:')
|
||||
for (const v of violations) console.error(` ${v}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('check:dist-fonts: dist makes no Google Fonts requests')
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Generates the PWA icons in web/public from the MealQuest logo.
|
||||
|
||||
Run from the repository root with Pillow installed:
|
||||
|
||||
python3 web/scripts/generate-icons.py
|
||||
|
||||
The logo fills the regular icons edge to edge. The maskable icon shrinks it
|
||||
onto the colour of its own parchment, so the banner stays inside
|
||||
the circular safe zone (radius 40% of the icon) that launchers may crop to.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageStat
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "design" / "logos" / "logo_v1_2Oct2026.jpg"
|
||||
PUBLIC = ROOT / "web" / "public"
|
||||
|
||||
MASKABLE_SCALE = 0.66
|
||||
|
||||
|
||||
def resized(logo: Image.Image, size: int) -> Image.Image:
|
||||
return logo.resize((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def maskable(logo: Image.Image, size: int) -> Image.Image:
|
||||
# The padding is the median colour of the logo's top edge, which is bare
|
||||
# parchment; a feathered edge hides the seam.
|
||||
top_edge = ImageStat.Stat(logo.crop((0, 0, logo.width, logo.height // 40)))
|
||||
canvas = Image.new("RGB", (size, size), tuple(round(c) for c in top_edge.median))
|
||||
inner = round(size * MASKABLE_SCALE)
|
||||
offset = (size - inner) // 2
|
||||
feather = Image.new("L", (inner, inner), 0)
|
||||
margin = inner // 24
|
||||
ImageDraw.Draw(feather).rectangle((margin, margin, inner - margin, inner - margin), fill=255)
|
||||
feather = feather.filter(ImageFilter.GaussianBlur(margin / 2))
|
||||
canvas.paste(resized(logo, inner), (offset, offset), feather)
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logo = Image.open(SOURCE).convert("RGB")
|
||||
if logo.width != logo.height:
|
||||
raise SystemExit(f"{SOURCE} is {logo.width}x{logo.height}; the logo must be square")
|
||||
outputs = {
|
||||
PUBLIC / "icons" / "icon-192.png": resized(logo, 192),
|
||||
PUBLIC / "icons" / "icon-512.png": resized(logo, 512),
|
||||
PUBLIC / "icons" / "icon-maskable-512.png": maskable(logo, 512),
|
||||
PUBLIC / "apple-touch-icon.png": resized(logo, 180),
|
||||
}
|
||||
for path, image in outputs.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(path, optimize=True)
|
||||
print(f"wrote {path.relative_to(ROOT)} ({image.width}x{image.height})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,42 +0,0 @@
|
||||
// Fails when a hex colour literal appears in any CSS or TypeScript file under
|
||||
// web/ outside the design tokens: every colour must come from a custom
|
||||
// property in tokens.css. Dependencies, build output and hidden directories
|
||||
// (tool caches) are not source and are skipped.
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const webRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const allowed = new Set(['src/styles/tokens.css'])
|
||||
const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')])
|
||||
const extensions = new Set(['.css', '.ts', '.tsx'])
|
||||
const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
|
||||
|
||||
function* sourceFiles(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path)
|
||||
} else if (extensions.has(extname(entry.name))) yield path
|
||||
}
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const path of sourceFiles(webRoot)) {
|
||||
const name = relative(webRoot, path).split('\\').join('/')
|
||||
if (allowed.has(name)) continue
|
||||
readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.forEach((line, i) => {
|
||||
for (const match of line.matchAll(hexColour)) {
|
||||
violations.push(`${name}:${i + 1}: ${match[0]}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Hex colour literals found; use a token from src/styles/tokens.css:')
|
||||
for (const v of violations) console.error(` ${v}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('lint:colors: no hex colour literals outside src/styles/tokens.css')
|
||||
+10
-96
@@ -1,101 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import App from './App.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, location = fakeLocation(path)) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App api={createApiClient(location)} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return location
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
it('registers every handoff route plus the extra settings pages', () => {
|
||||
expect(routes.map((route) => route.path)).toEqual([
|
||||
'/',
|
||||
'/onboarding',
|
||||
'/plan',
|
||||
'/plan/shopping',
|
||||
'/plan/shopping/archive',
|
||||
'/discover',
|
||||
'/quick',
|
||||
'/cook-from-pantry',
|
||||
'/dinner/:id',
|
||||
'/settings',
|
||||
'/settings/notifications',
|
||||
'/settings/ai',
|
||||
'/settings/household',
|
||||
'/settings/regulars',
|
||||
'/settings/calendar',
|
||||
])
|
||||
})
|
||||
|
||||
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
'%s renders its placeholder page inside the shell',
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
renderAt(path)
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
|
||||
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
||||
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the not-in-this-household screen on a 403', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const location = renderAt('/')
|
||||
|
||||
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
describe('App', () => {
|
||||
it('renders the MealQuest shell', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+13
-46
@@ -1,58 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
export const routes = [
|
||||
{ path: '/', title: 'Home' },
|
||||
{ path: '/onboarding', title: 'Onboarding' },
|
||||
{ path: '/plan', title: 'Plan' },
|
||||
{ path: '/plan/shopping', title: 'Supply run' },
|
||||
{ path: '/plan/shopping/archive', title: 'Supply archive' },
|
||||
{ path: '/discover', title: 'Discover' },
|
||||
{ path: '/quick', title: 'Quick dinners' },
|
||||
{ path: '/cook-from-pantry', title: 'Cook from what I have' },
|
||||
{ path: '/dinner/:id', title: 'Dinner' },
|
||||
{ path: '/settings', title: 'Settings' },
|
||||
{ path: '/settings/notifications', title: 'Notifications' },
|
||||
{ path: '/settings/ai', title: 'AI assistant' },
|
||||
{ path: '/settings/household', title: 'Household' },
|
||||
{ path: '/settings/regulars', title: 'Regulars' },
|
||||
{ path: '/settings/calendar', title: 'Calendar' },
|
||||
] as const
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
export function AppRoutes() {
|
||||
// Placeholder shell only: no product UI, auth or API calls yet.
|
||||
function Shell() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
<main>
|
||||
<h1>MealQuest</h1>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export type AppProps = {
|
||||
api: ApiClient
|
||||
}
|
||||
|
||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||
// MemoryRouter in tests).
|
||||
export default function App({ api }: AppProps) {
|
||||
const [queryClient] = useState(createQueryClient)
|
||||
export default function App() {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
return (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBootstrap>
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="*" element={<Shell />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ApiError, ME_PATH, useApi } from './api/index.ts'
|
||||
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
|
||||
import { ErrorBanner } from './ui/ErrorBanner.tsx'
|
||||
import { Skeleton } from './ui/Skeleton.tsx'
|
||||
import styles from './pages/FullScreen.module.css'
|
||||
|
||||
export const meQueryKey = [ME_PATH] as const
|
||||
|
||||
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
|
||||
// already navigating to sign-in, so the skeleton stays up; an account outside
|
||||
// the household sees only the "not in this household" screen.
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
|
||||
|
||||
if (forbidden) return <NotInHouseholdPage />
|
||||
if (me.isSuccess) return children
|
||||
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<ErrorBanner onRetry={() => void me.refetch()}>MealQuest could not load: {me.error.message}</ErrorBanner>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||
<div className={styles.loading}>
|
||||
<Skeleton className={styles.title} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { ApiClient } from './client.ts'
|
||||
|
||||
const ApiContext = createContext<ApiClient | null>(null)
|
||||
|
||||
export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
|
||||
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
|
||||
}
|
||||
|
||||
// useApi returns the app's API client. It must be rendered inside ApiProvider.
|
||||
export function useApi(): ApiClient {
|
||||
const client = useContext(ApiContext)
|
||||
if (client === null) throw new Error('useApi: no ApiProvider above this component')
|
||||
return client
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
|
||||
await expect(
|
||||
api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }),
|
||||
).resolves.toEqual({ ok: 2 })
|
||||
|
||||
const get = request(fetchMock, 1)
|
||||
expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' })
|
||||
expect(get.headers.has(CSRF_HEADER)).toBe(false)
|
||||
|
||||
const patch = request(fetchMock, 2)
|
||||
expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' })
|
||||
expect(patch.headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(patch.headers.get('Content-Type')).toBe('application/json')
|
||||
})
|
||||
|
||||
it('refuses a non-GET request before /me has loaded', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example/api/v1/me',
|
||||
'//evil.example/api/v1/me',
|
||||
// The placeholder origin an earlier guard compared against.
|
||||
'//same-origin.invalid/api/v1/me',
|
||||
'https://same-origin.invalid/api/v1/me',
|
||||
'/\\evil.example/api/v1/me',
|
||||
'/auth/logout',
|
||||
'/api/v1/../auth/logout',
|
||||
'/api/v1/%2e%2e/auth/logout',
|
||||
'api/v1/me',
|
||||
])('refuses %s without sending it or the CSRF token', async (path) => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
|
||||
`POST ${path}: not a same-origin /api/v1/ path`,
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects with a typed ApiError from the error body', async () => {
|
||||
stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
|
||||
})
|
||||
|
||||
it('rejects a response without the API error body', async () => {
|
||||
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
|
||||
})
|
||||
|
||||
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
|
||||
const api = createApiClient(location)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
|
||||
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
|
||||
'/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05',
|
||||
)
|
||||
})
|
||||
|
||||
it('on a csrf 403 refreshes /me once and retries with the new token', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
jsonResponse(201, { id: 'j1' }),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
|
||||
id: 'j1',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' })
|
||||
expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not loop when the retry gets a csrf 403 too', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
errorResponse(403, 'csrf'),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'csrf',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const listener = vi.fn()
|
||||
api.subscribe(listener)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(api.forbidden()).toBe(true)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not take a 403 without the API error body as not in this household', async () => {
|
||||
stubFetch(new Response('<html>denied by proxy</html>', { status: 403 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadMe', () => {
|
||||
it('rejects a /me response without a CSRF token', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
|
||||
})
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import { ApiError, isRecord, parseApiError } from './errors.ts'
|
||||
|
||||
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
|
||||
export const CSRF_HEADER = 'X-MealQuest-CSRF'
|
||||
|
||||
export const ME_PATH = '/api/v1/me'
|
||||
|
||||
// API_PREFIX is the only path apiFetch sends to.
|
||||
const API_PREFIX = '/api/v1/'
|
||||
|
||||
// apiURL resolves path against the page origin and returns it as the
|
||||
// path-absolute URL to fetch. It refuses any path that would not resolve to a
|
||||
// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere
|
||||
// else: absolute and protocol-relative URLs on another origin, and dot
|
||||
// segments that climb out of /api/v1/.
|
||||
function apiURL(origin: string, method: string, path: string): string {
|
||||
const url = new URL(path, origin)
|
||||
if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) {
|
||||
throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`)
|
||||
}
|
||||
return url.pathname + url.search
|
||||
}
|
||||
|
||||
// Me is the GET /api/v1/me response: the signed-in member, the household and
|
||||
// the session's CSRF token.
|
||||
export type Me = {
|
||||
name: string
|
||||
email: string
|
||||
household: { label: string }
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
// ApiLocation is the part of window.location the client reads and navigates.
|
||||
// origin is the page origin every request must stay on.
|
||||
export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'assign'>
|
||||
|
||||
export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON.
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export type ApiClient = {
|
||||
// apiFetch sends a same-origin JSON request to path, which must be under
|
||||
// /api/v1/, and resolves with the decoded response body, or rejects with an
|
||||
// ApiError.
|
||||
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
||||
loadMe(): Promise<Me>
|
||||
// forbidden reports whether a request was refused because the signed-in
|
||||
// account is not in this household; subscribe is notified when it changes.
|
||||
forbidden(): boolean
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
// createApiClient builds the app's API client. location is window.location in
|
||||
// the app: a 401 sends it to /auth/login with the current path to return to.
|
||||
export function createApiClient(location: ApiLocation): ApiClient {
|
||||
let csrfToken: string | null = null
|
||||
let refreshing: Promise<Me> | null = null
|
||||
let forbidden = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
async function send(method: string, path: string, body: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (method !== 'GET') {
|
||||
if (csrfToken === null) {
|
||||
throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`)
|
||||
}
|
||||
headers[CSRF_HEADER] = csrfToken
|
||||
}
|
||||
return fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// refreshMe shares one in-flight /me request between every request that
|
||||
// got a csrf 403 at the same time.
|
||||
function refreshMe(): Promise<Me> {
|
||||
refreshing ??= loadMe().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
const method = request.method ?? 'GET'
|
||||
const url = apiURL(location.origin, method, path)
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
// Only a non-GET carries the token, so only it can be refused for it.
|
||||
if (error.code !== 'csrf' || method === 'GET') throw error
|
||||
// The session's token changed: refresh it once and retry once.
|
||||
await refreshMe()
|
||||
response = await send(method, url, request.body)
|
||||
if (response.status === 403) throw await parseForbidden(method, path, response)
|
||||
}
|
||||
return decode<T>(method, path, response)
|
||||
}
|
||||
|
||||
// parseForbidden reads a 403. Any API error other than csrf means the
|
||||
// account is not in this household.
|
||||
async function parseForbidden(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const error = await parseApiError(method, path, response)
|
||||
if (error.code !== 'csrf') markForbidden()
|
||||
return error
|
||||
}
|
||||
|
||||
function markForbidden() {
|
||||
if (forbidden) return
|
||||
forbidden = true
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
|
||||
if (response.status === 401) {
|
||||
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
|
||||
}
|
||||
if (!response.ok) throw await parseApiError(method, path, response)
|
||||
if (response.status === 204) return undefined as T
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function loadMe(): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH)
|
||||
if (
|
||||
!isRecord(me) ||
|
||||
typeof me.name !== 'string' ||
|
||||
typeof me.email !== 'string' ||
|
||||
!isRecord(me.household) ||
|
||||
typeof me.household.label !== 'string' ||
|
||||
typeof me.csrfToken !== 'string' ||
|
||||
me.csrfToken === ''
|
||||
) {
|
||||
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
|
||||
}
|
||||
csrfToken = me.csrfToken
|
||||
return me as Me
|
||||
}
|
||||
|
||||
return {
|
||||
apiFetch,
|
||||
loadMe,
|
||||
forbidden: () => forbidden,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// ApiError is a failed API response carrying the server's
|
||||
// {"error":{"code","message","retryable"}} body.
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
readonly retryable: boolean
|
||||
|
||||
constructor(status: number, code: string, message: string, retryable: boolean) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.retryable = retryable
|
||||
}
|
||||
}
|
||||
|
||||
// parseApiError reads an error response body. A body that is not the API
|
||||
// error shape is itself an error: the server always sends one.
|
||||
export async function parseApiError(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const text = await response.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const error = isRecord(body) ? body.error : undefined
|
||||
if (
|
||||
!isRecord(error) ||
|
||||
typeof error.code !== 'string' ||
|
||||
typeof error.message !== 'string' ||
|
||||
typeof error.retryable !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${method} ${path}: ${response.status} response has no API error body`)
|
||||
}
|
||||
return new ApiError(response.status, error.code, error.message, error.retryable)
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { ApiProvider, useApi } from './ApiContext.tsx'
|
||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
||||
export { ApiError } from './errors.ts'
|
||||
export { createQueryClient, shouldRetry } from './queryClient.ts'
|
||||
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError, createQueryClient, shouldRetry } from './index.ts'
|
||||
|
||||
describe('query retries', () => {
|
||||
it('retry only an ApiError the server marked retryable, a bounded number of times', () => {
|
||||
expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true)
|
||||
expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false)
|
||||
expect(shouldRetry(0, new Error('network down'))).toBe(false)
|
||||
})
|
||||
|
||||
it('are the QueryClient default for queries and mutations', () => {
|
||||
const options = createQueryClient().getDefaultOptions()
|
||||
expect(options.queries?.retry).toBe(shouldRetry)
|
||||
expect(options.mutations?.retry).toBe(shouldRetry)
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError } from './errors.ts'
|
||||
|
||||
// MAX_RETRIES bounds retries of a failure the server marked retryable.
|
||||
export const MAX_RETRIES = 3
|
||||
|
||||
// shouldRetry retries only an ApiError the server marked retryable — never a
|
||||
// 401, a 403 or a malformed response.
|
||||
export function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||
return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: shouldRetry, staleTime: 30_000 },
|
||||
mutations: { retry: shouldRetry },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiProvider, createApiClient, useJob } from './index.ts'
|
||||
|
||||
type Draft = { dinners: string[] }
|
||||
|
||||
function job(status: string, result: Draft | null, error: string | null = null) {
|
||||
return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error })
|
||||
}
|
||||
|
||||
function renderJob(jobId: string | null) {
|
||||
const api = createApiClient(fakeLocation())
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
||||
}
|
||||
|
||||
// advance runs the timers due in ms and lets the polls they start answer.
|
||||
async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useJob', () => {
|
||||
it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('queued', null),
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
|
||||
await advance(0)
|
||||
expect(result.current.status).toBe('queued')
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' })
|
||||
|
||||
await advance(499)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
await advance(1)
|
||||
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] })
|
||||
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
expect(result.current).toEqual({
|
||||
status: 'succeeded',
|
||||
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Terminal: no timer left, no further polls.
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('exposes a failed job as its error and stops', async () => {
|
||||
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused'))
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(result.current.status).toBe('failed')
|
||||
expect(result.current.error?.message).toBe('model refused')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
errorResponse(503, 'unavailable', true),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
|
||||
expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// The backoff carries on from the failed poll: the next one is 1 s later.
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stops polling on unmount after a failed poll', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(result.current).toMatchObject({ status: undefined, result: null })
|
||||
expect(result.current.error).toMatchObject({ status: 503 })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops polling the old job when the job id changes', async () => {
|
||||
const fetchMock = stubFetch(job('running', null), job('running', null))
|
||||
const { result, rerender } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
rerender({ id: 'j2' })
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
await advance(0)
|
||||
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('stops polling on unmount', async () => {
|
||||
const fetchMock = stubFetch(job('running', null))
|
||||
const { unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a poll that answers after unmount', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
const fetchMock = stubFetch()
|
||||
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
unmount()
|
||||
await act(async () => answer(await Promise.resolve(job('running', null))))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(result.current.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('polls nothing without a job id', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const { result } = renderJob(null)
|
||||
await advance(5000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useApi } from './ApiContext.tsx'
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
|
||||
|
||||
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
|
||||
// is running (a partial result); error is set only when it failed.
|
||||
export type Job<R> = {
|
||||
id: string
|
||||
kind: string
|
||||
status: JobStatus
|
||||
result: R | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type JobState<R> = {
|
||||
// undefined until the first poll answers.
|
||||
status: JobStatus | undefined
|
||||
// The latest result, partial while the job runs.
|
||||
result: R | null
|
||||
// The job's failure, or the latest polling request's own failure (cleared
|
||||
// by the next poll that answers).
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
|
||||
export const FIRST_DELAY_MS = 500
|
||||
export const MAX_DELAY_MS = 2000
|
||||
|
||||
const idle = { status: undefined, result: null, error: null }
|
||||
|
||||
function isTerminal(status: JobStatus): boolean {
|
||||
return status === 'succeeded' || status === 'failed'
|
||||
}
|
||||
|
||||
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
|
||||
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
|
||||
// polling continues. Polling stops only at a terminal status, when jobId
|
||||
// changes and on unmount. A null jobId polls nothing.
|
||||
export function useJob<R>(jobId: string | null): JobState<R> {
|
||||
const { apiFetch } = useApi()
|
||||
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId === null) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let delay = FIRST_DELAY_MS
|
||||
|
||||
function next() {
|
||||
timer = setTimeout(poll, delay)
|
||||
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
|
||||
if (!active) return
|
||||
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
|
||||
setState({ jobId, job: { status: job.status, result: job.result, error } })
|
||||
if (isTerminal(job.status)) return
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
// A failed poll is not the job's end: keep the last known status and
|
||||
// result, expose the request's error, and poll again.
|
||||
setState((prev) => ({
|
||||
jobId,
|
||||
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
|
||||
}))
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
void poll()
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [apiFetch, jobId])
|
||||
|
||||
// State left from a previous jobId is not this job's.
|
||||
return state.jobId === jobId ? state.job : idle
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,93 +0,0 @@
|
||||
Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -1,93 +0,0 @@
|
||||
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
+1
-6
@@ -1,22 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
throw new Error('MealQuest: #root element missing from index.html')
|
||||
}
|
||||
|
||||
const api = createApiClient(window.location)
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App api={api} />
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/* A full-viewport screen shown instead of the app shell. */
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: min(100%, 480px);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 32px;
|
||||
width: 60%;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import styles from './FullScreen.module.css'
|
||||
|
||||
// Shown when the server refuses a signed-in account that is not in this
|
||||
// household. Nothing else renders: the account can do nothing here.
|
||||
export function NotInHouseholdPage() {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<EmptyState title="Not in this household">
|
||||
You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it.
|
||||
</EmptyState>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type PlaceholderPageProps = {
|
||||
// The route's screen name, shown as the page heading.
|
||||
title: string
|
||||
}
|
||||
|
||||
// Stands in for a screen until its feature is built.
|
||||
export function PlaceholderPage({ title }: PlaceholderPageProps) {
|
||||
return <h1>{title}</h1>
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// @vitest-environment node
|
||||
// Builds the app with the production config and serves the result, so the
|
||||
// assertions cover what ships in dist: the web manifest, the service worker,
|
||||
// every icon, and the service worker registration that only builds include.
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
|
||||
import tokens from './styles/tokens.css?raw'
|
||||
|
||||
const outDir = 'node_modules/.tmp/pwa-test-dist'
|
||||
|
||||
const icons = [
|
||||
{ path: '/icons/icon-192.png', size: 192 },
|
||||
{ path: '/icons/icon-512.png', size: 512 },
|
||||
{ path: '/icons/icon-maskable-512.png', size: 512 },
|
||||
{ path: '/apple-touch-icon.png', size: 180 },
|
||||
]
|
||||
|
||||
function token(name: string): string {
|
||||
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
|
||||
if (!value) throw new Error(`${name} is not defined in tokens.css`)
|
||||
return value
|
||||
}
|
||||
|
||||
// Width and height from a PNG's IHDR chunk, which follows the 8-byte
|
||||
// signature, the chunk length and the chunk type.
|
||||
// The precache list vite-plugin-pwa injects into sw.js: a JSON array of
|
||||
// { revision, url } entries.
|
||||
function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> {
|
||||
const start = sw.indexOf('[{"revision":')
|
||||
expect(start, 'injected precache list').toBeGreaterThanOrEqual(0)
|
||||
return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2))
|
||||
}
|
||||
|
||||
function pngSize(bytes: Uint8Array): { width: number; height: number } {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
expect(Array.from(bytes.subarray(0, 8))).toEqual(signature)
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
return { width: view.getUint32(16), height: view.getUint32(20) }
|
||||
}
|
||||
|
||||
describe('production build', () => {
|
||||
let server: PreviewServer
|
||||
let origin: string
|
||||
|
||||
async function get(path: string): Promise<Response> {
|
||||
const response = await fetch(new URL(path, origin))
|
||||
expect(response.status, path).toBe(200)
|
||||
return response
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
|
||||
server = await preview({
|
||||
logLevel: 'error',
|
||||
build: { outDir },
|
||||
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
|
||||
})
|
||||
const url = server.resolvedUrls?.local[0]
|
||||
if (!url) throw new Error('preview server did not report a URL')
|
||||
origin = url
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('emits manifest.webmanifest with the MealQuest identity', async () => {
|
||||
const manifest = await (await get('/manifest.webmanifest')).json()
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'MealQuest',
|
||||
short_name: 'MealQuest',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
// --color-accent and --color-bg hold the manifest colours; hex colours
|
||||
// may not be written outside tokens.css.
|
||||
theme_color: token('--color-accent'),
|
||||
background_color: token('--color-bg'),
|
||||
})
|
||||
expect(manifest.icons).toEqual([
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{
|
||||
src: '/icons/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
|
||||
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
|
||||
expect(pngSize(bytes)).toEqual({ width: size, height: size })
|
||||
})
|
||||
|
||||
it('emits sw.js with the precache list injected', async () => {
|
||||
const sw = await (await get('/sw.js')).text()
|
||||
expect(sw).not.toContain('self.__WB_MANIFEST')
|
||||
expect(sw).toContain('"url":"index.html"')
|
||||
for (const { path } of icons.slice(0, 3)) {
|
||||
expect(sw).toContain(`"url":"${path.slice(1)}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('precaches the shell, JS, CSS and every self-hosted font', async () => {
|
||||
const entries = precacheEntries(await (await get('/sw.js')).text())
|
||||
const urls = entries.map((entry) => entry.url)
|
||||
// index.html keeps its URL across builds, so it carries a content
|
||||
// revision; the other assets have content hashes in their names.
|
||||
expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(urls.some((url) => url.endsWith('.js'))).toBe(true)
|
||||
expect(urls.some((url) => url.endsWith('.css'))).toBe(true)
|
||||
for (const font of [
|
||||
'BagelFatOne-LatinExt',
|
||||
'BagelFatOne-Latin',
|
||||
'Figtree-Regular',
|
||||
'Figtree-SemiBold',
|
||||
'Figtree-Bold',
|
||||
]) {
|
||||
expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
|
||||
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
|
||||
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
|
||||
})
|
||||
|
||||
it('registers /sw.js at scope / from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
|
||||
const register = await (await get('/registerSW.js')).text()
|
||||
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev server', () => {
|
||||
let server: ViteDevServer
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('does not register a service worker', async () => {
|
||||
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
|
||||
expect(html).not.toContain('registerSW')
|
||||
expect(html).not.toContain('serviceWorker')
|
||||
expect(html).not.toContain('rel="manifest"')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
.shell {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The bottom bar overlays the last 94 px of the screen (plus the safe area),
|
||||
so the scroll area reserves at least 100 px beneath its content. */
|
||||
.mobile .main {
|
||||
padding: 0 20px;
|
||||
padding-bottom: calc(100px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.tablet .main,
|
||||
.desktop .main {
|
||||
padding: 32px 36px;
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import bottomBarStyles from './BottomBar.module.css'
|
||||
import railStyles from './Rail.module.css'
|
||||
import sidebarStyles from './Sidebar.module.css'
|
||||
|
||||
const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings']
|
||||
const MOBILE = 390
|
||||
const TABLET = 1180
|
||||
const DESKTOP = 1440
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function renderAt(path: string, width: number) {
|
||||
const resize = mockViewport(width)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return resize
|
||||
}
|
||||
|
||||
function nav() {
|
||||
return screen.getByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function navLinks() {
|
||||
return within(nav()).getAllByRole('link')
|
||||
}
|
||||
|
||||
function current() {
|
||||
return navLinks()
|
||||
.filter((link) => link.getAttribute('aria-current') === 'page')
|
||||
.map((link) => link.textContent)
|
||||
}
|
||||
|
||||
describe('AppShell', () => {
|
||||
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
|
||||
renderAt('/', width)
|
||||
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
|
||||
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/',
|
||||
'/plan',
|
||||
'/discover',
|
||||
'/quick',
|
||||
'/settings',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows only the mobile bottom bar below 768 px', () => {
|
||||
renderAt('/', 767)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
||||
|
||||
const bar = declaredStyle(nav())
|
||||
expect(bar.position).toBe('absolute')
|
||||
expect(bar.left).toBe('12px')
|
||||
expect(bar.right).toBe('12px')
|
||||
expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))')
|
||||
expect(bar.height).toBe('62px')
|
||||
expect(outerSize(nav(), 'height')).toBe(62)
|
||||
expect(bar.background).toBe('var(--color-text)')
|
||||
expect(bar.borderRadius).toBe('var(--radius-pill)')
|
||||
})
|
||||
|
||||
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
|
||||
renderAt('/discover', MOBILE)
|
||||
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
|
||||
expect(pills).toHaveLength(1)
|
||||
const pill = declaredStyle(pills[0]!)
|
||||
expect(pill.position).toBe('absolute')
|
||||
expect(pill.top).toBe('10px')
|
||||
expect(pill.height).toBe('42px')
|
||||
expect(pill.background).toBe('var(--color-accent)')
|
||||
expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex))
|
||||
|
||||
const active = screen.getByRole('link', { name: 'Discover' })
|
||||
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
||||
expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!)
|
||||
expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px')
|
||||
|
||||
const inactive = screen.getByRole('link', { name: 'Plan' })
|
||||
expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)')
|
||||
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
|
||||
})
|
||||
|
||||
it('makes the full 62 px bar height the mobile tap target', () => {
|
||||
renderAt('/', MOBILE)
|
||||
for (const link of navLinks()) {
|
||||
expect(declaredStyle(link).height).toBe('62px')
|
||||
expectMinHitArea(link)
|
||||
}
|
||||
})
|
||||
|
||||
it('reserves at least 100 px below the mobile scroll area', () => {
|
||||
renderAt('/', MOBILE)
|
||||
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
|
||||
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
|
||||
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
|
||||
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
|
||||
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
|
||||
renderAt('/plan', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
expect(declaredStyle(nav()).width).toBe('96px')
|
||||
expect(outerSize(nav(), 'width')).toBe(96)
|
||||
expect(declaredStyle(nav()).background).toBe('var(--color-surface)')
|
||||
const mark = within(nav()).getByText('M')
|
||||
expect(declaredStyle(mark).width).toBe('48px')
|
||||
expect(outerSize(mark, 'width')).toBe(48)
|
||||
expect(outerSize(mark, 'height')).toBe(48)
|
||||
expect(declaredStyle(mark).background).toBe('var(--color-accent)')
|
||||
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
||||
|
||||
for (const link of navLinks()) {
|
||||
const pill = link.firstElementChild!
|
||||
expect(declaredStyle(pill).width).toBe('56px')
|
||||
expect(declaredStyle(pill).height).toBe('36px')
|
||||
expect(outerSize(pill, 'width')).toBe(56)
|
||||
expect(outerSize(pill, 'height')).toBe(36)
|
||||
expect(declaredStyle(link).fontSize).toBe('11px')
|
||||
expect(declaredStyle(link).fontWeight).toBe('700')
|
||||
expectMinHitArea(link)
|
||||
}
|
||||
expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!)
|
||||
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
|
||||
})
|
||||
|
||||
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
|
||||
renderAt('/quick', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
const sidebar = nav().closest('aside')!
|
||||
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
|
||||
expect(declaredStyle(sidebar).width).toBe('208px')
|
||||
expect(declaredStyle(sidebar).background).toBe('var(--color-surface)')
|
||||
expect(declaredStyle(sidebar).padding).toBe('28px 16px')
|
||||
// The 16 px side padding sits inside the 208 px, not outside it.
|
||||
expect(outerSize(sidebar, 'width')).toBe(208)
|
||||
|
||||
const wordmark = within(sidebar).getByText('MealQuest')
|
||||
expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)')
|
||||
expect(declaredStyle(wordmark).fontSize).toBe('28px')
|
||||
expect(declaredStyle(wordmark).color).toBe('var(--color-accent)')
|
||||
|
||||
expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument()
|
||||
expect(within(sidebar).getByText('Shared household')).toBeInTheDocument()
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||
|
||||
const active = screen.getByRole('link', { name: 'Quick' })
|
||||
expect(active).toHaveClass(sidebarStyles.active!)
|
||||
expect(declaredStyle(active).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
||||
expect(declaredStyle(active).fontSize).toBe('15px')
|
||||
expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!)
|
||||
// Rows stretch across the sidebar, so only their height needs declaring.
|
||||
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
|
||||
})
|
||||
|
||||
it('switches layout when the viewport crosses a breakpoint', () => {
|
||||
const resize = renderAt('/', MOBILE)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
resize(TABLET)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
resize(DESKTOP)
|
||||
expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!)
|
||||
resize(MOBILE)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['mobile', MOBILE],
|
||||
['tablet', TABLET],
|
||||
['desktop', DESKTOP],
|
||||
])('aria-current (%s)', (_layout, width) => {
|
||||
it.each([
|
||||
['/', ['Home']],
|
||||
['/plan', ['Plan']],
|
||||
['/plan/shopping', ['Plan']],
|
||||
['/plan/shopping/archive', ['Plan']],
|
||||
['/discover', ['Discover']],
|
||||
['/quick', ['Quick']],
|
||||
['/settings', ['Settings']],
|
||||
['/settings/notifications', ['Settings']],
|
||||
['/settings/ai', ['Settings']],
|
||||
['/settings/household', ['Settings']],
|
||||
['/settings/regulars', ['Settings']],
|
||||
['/settings/calendar', ['Settings']],
|
||||
['/onboarding', []],
|
||||
['/cook-from-pantry', []],
|
||||
['/dinner/abc123', []],
|
||||
])('%s marks %j current', (path, expected) => {
|
||||
renderAt(path, width)
|
||||
expect(current()).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Outlet } from 'react-router'
|
||||
import { BottomBar } from './BottomBar.tsx'
|
||||
import { Rail } from './Rail.tsx'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { useBreakpoint } from './useBreakpoint.ts'
|
||||
import styles from './AppShell.module.css'
|
||||
|
||||
// The responsive frame around every page: one nav layout per breakpoint and a
|
||||
// scrolling main area for the routed page.
|
||||
export function AppShell() {
|
||||
const breakpoint = useBreakpoint()
|
||||
return (
|
||||
<div className={`${styles.shell} ${styles[breakpoint]}`}>
|
||||
{breakpoint === 'mobile' && <BottomBar />}
|
||||
{breakpoint === 'tablet' && <Rail />}
|
||||
{breakpoint === 'desktop' && <Sidebar />}
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
height: 62px;
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-text);
|
||||
}
|
||||
|
||||
/* The one grape pill, shared by every item: BottomBar sets its left and width
|
||||
to the active item's and slides it between items. */
|
||||
.pill {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
height: 42px;
|
||||
z-index: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The whole bar height is the tap target; the 42 px pill sits behind it.
|
||||
Items stay above the pill. */
|
||||
.item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 62px;
|
||||
min-width: 44px;
|
||||
padding: 0 13px;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-neutral-400);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color .2s;
|
||||
}
|
||||
|
||||
.item[aria-current='page'] {
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Inactive items are icon-only on screen but keep their name for assistive tech. */
|
||||
.hiddenLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
const MOBILE = 390
|
||||
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
|
||||
|
||||
// jsdom does no layout, so lay the bar out here: items sit side by side from
|
||||
// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px.
|
||||
function layOut(element: HTMLElement): { left: number; width: number } {
|
||||
const bar = element.parentElement!
|
||||
const items = Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
|
||||
let left = 6
|
||||
for (const item of items) {
|
||||
const width = item.getAttribute('aria-current') === 'page' ? 110 : 48
|
||||
if (item === element) return { left, width }
|
||||
left += width
|
||||
}
|
||||
return { left: 0, width: 0 }
|
||||
}
|
||||
|
||||
// jsdom paints nothing either, so bounding boxes come from the laid-out
|
||||
// offsets with the bar at the viewport's left edge. A test mid-slide sets
|
||||
// where the pill is drawn and how far each item is still translated.
|
||||
let drawnPill: { left: number; width: number } | null
|
||||
let translated: Map<Element, number>
|
||||
|
||||
function box(left: number, width: number): DOMRect {
|
||||
return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect
|
||||
}
|
||||
|
||||
let animate: Mock<Element['animate']>
|
||||
|
||||
beforeEach(() => {
|
||||
drawnPill = null
|
||||
translated = new Map()
|
||||
// jsdom has no Web Animations API.
|
||||
animate = vi.fn<Element['animate']>(() => ({ cancel: vi.fn() }) as unknown as Animation)
|
||||
Element.prototype.animate = animate
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (!(this instanceof HTMLElement)) return box(0, 0)
|
||||
if (this.classList.contains(styles.pill!)) {
|
||||
return drawnPill
|
||||
? box(drawnPill.left, drawnPill.width)
|
||||
: box(parseFloat(this.style.left), parseFloat(this.style.width))
|
||||
}
|
||||
if (this.classList.contains(styles.item!)) {
|
||||
return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth)
|
||||
}
|
||||
return box(0, 0)
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return layOut(this).left
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return layOut(this).width
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(Element.prototype, 'animate')
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function renderAt(path: string, reducedMotion = false) {
|
||||
mockViewport(MOBILE, { reducedMotion })
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
function nav() {
|
||||
return screen.getByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function pill(): HTMLElement {
|
||||
return nav().querySelector<HTMLElement>(`.${styles.pill}`)!
|
||||
}
|
||||
|
||||
function link(name: string): HTMLElement {
|
||||
return within(nav()).getByRole('link', { name })
|
||||
}
|
||||
|
||||
function runningOn(element: Element): Animation[] {
|
||||
return animate.mock.contexts
|
||||
.map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation }))
|
||||
.filter(({ target }) => target === element)
|
||||
.map(({ animation }) => animation)
|
||||
}
|
||||
|
||||
function animationsOf(element: Element) {
|
||||
return animate.mock.contexts
|
||||
.map((target, i) => ({ target, args: animate.mock.calls[i]! }))
|
||||
.filter(({ target }) => target === element)
|
||||
.map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions }))
|
||||
}
|
||||
|
||||
describe('BottomBar', () => {
|
||||
it('places the pill on the active item without animating on first render', () => {
|
||||
renderAt('/plan')
|
||||
expect(pill().style.left).toBe('54px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
expect(pill()).toBeVisible()
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the pill when no item is active', () => {
|
||||
renderAt('/onboarding')
|
||||
expect(pill()).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
expect(animationsOf(pill())).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ left: '6px', width: '110px' },
|
||||
{ left: '150px', width: '110px' },
|
||||
],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(pill().style.left).toBe('150px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
|
||||
// and Quick grows in place; Settings ends where it started.
|
||||
for (const name of ['Plan', 'Discover']) {
|
||||
expect(animationsOf(link(name))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
}
|
||||
expect(animationsOf(link('Quick'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(animationsOf(link('Home'))).toEqual([])
|
||||
expect(animationsOf(link('Settings'))).toEqual([])
|
||||
})
|
||||
|
||||
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Discover'))
|
||||
|
||||
const label = within(link('Discover')).getByText('Discover')
|
||||
expect(label).toHaveClass(styles.label!)
|
||||
expect(animationsOf(label)).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ opacity: 0, transform: 'translateX(-4px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not animate when the active item stays the same', () => {
|
||||
renderAt('/plan')
|
||||
fireEvent.click(link('Plan'))
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('jumps the pill to the new item with no animation under reduced motion', () => {
|
||||
renderAt('/', true)
|
||||
fireEvent.click(link('Settings'))
|
||||
|
||||
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
expect(pill().style.left).toBe('198px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('re-places the pill when the viewport resizes', () => {
|
||||
renderAt('/discover')
|
||||
expect(pill().style.left).toBe('102px')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(pill().style.left).toBe('120px')
|
||||
expect(pill().style.width).toBe('96px')
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a second slide from where the first one is on screen, not from its destination', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const [firstPill] = runningOn(pill())
|
||||
const firstPlan = runningOn(link('Plan'))[0]!
|
||||
|
||||
// Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan
|
||||
// still carries 31 of its 62 px FLIP translation.
|
||||
drawnPill = { left: 78, width: 110 }
|
||||
translated.set(link('Plan'), 31)
|
||||
animate.mockClear()
|
||||
fireEvent.click(link('Settings'))
|
||||
|
||||
expect(firstPill!.cancel).toHaveBeenCalled()
|
||||
expect(firstPlan.cancel).toHaveBeenCalled()
|
||||
expect(animationsOf(pill())).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ left: '78px', width: '110px' },
|
||||
{ left: '198px', width: '110px' },
|
||||
],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
// Plan's layout does not move between Quick and Settings, so it only
|
||||
// finishes the 31 px it had left. Settings moves 62 px left.
|
||||
expect(animationsOf(link('Plan'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(animationsOf(link('Settings'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
|
||||
expect(slide).toHaveLength(4)
|
||||
|
||||
animate.mockClear()
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||
fireEvent(window, new Event('resize'))
|
||||
|
||||
for (const animation of slide) expect(animation.cancel).toHaveBeenCalled()
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
expect(pill().style.left).toBe('140px')
|
||||
expect(pill().style.width).toBe('96px')
|
||||
})
|
||||
|
||||
it('re-places the pill once the web fonts are ready', async () => {
|
||||
let fontsReady!: () => void
|
||||
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
|
||||
try {
|
||||
renderAt('/discover')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
|
||||
fontsReady()
|
||||
await ready
|
||||
expect(pill().style.width).toBe('124px')
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, 'fonts')
|
||||
}
|
||||
})
|
||||
|
||||
it('crossfades the icon colour with a .2s colour transition', () => {
|
||||
renderAt('/')
|
||||
for (const item of within(nav()).getAllByRole('link')) {
|
||||
expect(declaredStyle(item).transition).toBe('color .2s')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
// The handoff's Motion §1 timing. SPRING is the --ease-spring token's value.
|
||||
const SLIDE_MS = 420
|
||||
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
|
||||
const LABEL_MS = 220
|
||||
const LABEL_DELAY_MS = 120
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
// The active item and every item's layout offset within the bar when the pill
|
||||
// was last placed (offsets ignore transforms, so a running FLIP does not skew
|
||||
// them).
|
||||
type Geometry = {
|
||||
active: HTMLElement | null
|
||||
itemLefts: number[]
|
||||
}
|
||||
|
||||
// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive
|
||||
// items show only their icon; the active item adds its label. One shared
|
||||
// grape pill sits behind the items and slides to the active one.
|
||||
export function BottomBar() {
|
||||
const barRef = useRef<HTMLElement>(null)
|
||||
const pillRef = useRef<HTMLSpanElement>(null)
|
||||
const placed = useRef<Geometry | null>(null)
|
||||
// The pill slide and item FLIPs currently running.
|
||||
const sliding = useRef<Animation[]>([])
|
||||
const stopSlide = () => {
|
||||
sliding.current.forEach((animation) => animation.cancel())
|
||||
sliding.current = []
|
||||
}
|
||||
const { pathname } = useLocation()
|
||||
|
||||
// After each navigation: if the active item changed, move the pill to it and,
|
||||
// motion allowed, slide the pill, FLIP the items and fade the label in. A
|
||||
// slide still running is measured where it is on screen, then stopped, so
|
||||
// the next one starts from there instead of from its destination.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
const active = activeItem(bar)
|
||||
const before = placed.current
|
||||
if (before && before.active === active) return
|
||||
const from = before && onScreen(bar, pill, before)
|
||||
stopSlide()
|
||||
placed.current = place(bar, pill, active)
|
||||
if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return
|
||||
|
||||
const after = placed.current
|
||||
if (before.active) {
|
||||
sliding.current.push(
|
||||
pill.animate(
|
||||
[
|
||||
{ left: `${from.pill.left}px`, width: `${from.pill.width}px` },
|
||||
{ left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` },
|
||||
],
|
||||
{ duration: SLIDE_MS, easing: SPRING },
|
||||
),
|
||||
)
|
||||
}
|
||||
items(bar).forEach((item, i) => {
|
||||
const dx = from.itemLefts[i]! - after.itemLefts[i]!
|
||||
if (dx === 0) return
|
||||
sliding.current.push(
|
||||
item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], {
|
||||
duration: SLIDE_MS,
|
||||
easing: SPRING,
|
||||
}),
|
||||
)
|
||||
})
|
||||
active.querySelector(`.${styles.label}`)!.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'translateX(-4px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
{ duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' },
|
||||
)
|
||||
}, [pathname])
|
||||
|
||||
// Item widths change with the viewport and once the web fonts arrive, so
|
||||
// stop any running slide and re-place the pill (without animating) on both. A failed font load
|
||||
// rejects fonts.ready and is left to surface as an unhandled rejection.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
let mounted = true
|
||||
const replace = () => {
|
||||
if (!mounted) return
|
||||
stopSlide()
|
||||
placed.current = place(bar, pill, activeItem(bar))
|
||||
}
|
||||
window.addEventListener('resize', replace)
|
||||
// jsdom has no document.fonts; every browser the app targets does.
|
||||
if (document.fonts) void document.fonts.ready.then(replace)
|
||||
return () => {
|
||||
mounted = false
|
||||
window.removeEventListener('resize', replace)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav ref={barRef} aria-label="Primary" className={styles.bar}>
|
||||
<span ref={pillRef} className={styles.pill} aria-hidden="true" />
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
<span className={isActive ? styles.label : styles.hiddenLabel}>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function items(bar: HTMLElement): HTMLElement[] {
|
||||
return Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
|
||||
}
|
||||
|
||||
// NavLink's aria-current is the one record of which item is active.
|
||||
function activeItem(bar: HTMLElement): HTMLElement | null {
|
||||
return bar.querySelector<HTMLElement>(`.${styles.item}[aria-current="page"]`)
|
||||
}
|
||||
|
||||
// Where the pill and items are on screen right now, as offsets within the
|
||||
// bar. Bounding boxes include a running slide: the pill's box is its animated
|
||||
// left and width, and an item's box is its current layout plus whatever FLIP
|
||||
// translation remains, which applies to the layout it had when last placed.
|
||||
function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) {
|
||||
const origin = bar.getBoundingClientRect().left + bar.clientLeft
|
||||
const pillBox = pill.getBoundingClientRect()
|
||||
return {
|
||||
pill: { left: pillBox.left - origin, width: pillBox.width },
|
||||
itemLefts: items(bar).map(
|
||||
(item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Puts the pill on the active item (hidden when no item is active) and
|
||||
// returns the resulting geometry.
|
||||
function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry {
|
||||
pill.hidden = !active
|
||||
pill.style.left = `${active?.offsetLeft ?? 0}px`
|
||||
pill.style.width = `${active?.offsetWidth ?? 0}px`
|
||||
return { active, itemLefts: items(bar).map((item) => item.offsetLeft) }
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
.rail {
|
||||
width: 96px;
|
||||
padding: 24px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 56px;
|
||||
min-height: 44px;
|
||||
color: var(--color-neutral-700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
width: 56px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { NavLink } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './Rail.module.css'
|
||||
|
||||
// Tablet nav: a narrow icon rail with the "M" mark on top.
|
||||
export function Rail() {
|
||||
return (
|
||||
<nav aria-label="Primary" className={styles.rail}>
|
||||
<div className={styles.mark} aria-hidden="true">
|
||||
M
|
||||
</div>
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
</span>
|
||||
{item.label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
.sidebar {
|
||||
width: 208px;
|
||||
padding: 28px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin: 0;
|
||||
padding: 0 12px 22px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--color-neutral-800);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.household {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.householdText {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.householdNote {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NavLink } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared
|
||||
// household card at the foot.
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<p className={styles.wordmark}>MealQuest</p>
|
||||
<nav aria-label="Primary" className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) => (isActive ? `${styles.item} ${styles.active}` : styles.item)}
|
||||
>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className={styles.household}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
K
|
||||
</span>
|
||||
<p className={styles.householdText}>
|
||||
<strong>Our kitchen</strong>
|
||||
<span className={styles.householdNote}>Shared household</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react'
|
||||
|
||||
export type NavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
// Match the path exactly; otherwise nested paths (/plan/shopping,
|
||||
// /settings/ai) also mark the item current.
|
||||
end: boolean
|
||||
}
|
||||
|
||||
// The primary nav, in the same order in every layout.
|
||||
export const navItems: readonly NavItem[] = [
|
||||
{ to: '/', label: 'Home', icon: House, end: true },
|
||||
{ to: '/plan', label: 'Plan', icon: Calendar, end: false },
|
||||
{ to: '/discover', label: 'Discover', icon: Compass, end: false },
|
||||
{ to: '/quick', label: 'Quick', icon: Zap, end: false },
|
||||
{ to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false },
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useCallback, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||
|
||||
// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px,
|
||||
// desktop ≥ 1200 px.
|
||||
export const TABLET_QUERY = '(min-width: 768px)'
|
||||
export const DESKTOP_QUERY = '(min-width: 1200px)'
|
||||
|
||||
// The layout the current viewport calls for, updated as the viewport crosses
|
||||
// a breakpoint.
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const [lists] = useState(() => ({
|
||||
tablet: window.matchMedia(TABLET_QUERY),
|
||||
desktop: window.matchMedia(DESKTOP_QUERY),
|
||||
}))
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
lists.tablet.addEventListener('change', onChange)
|
||||
lists.desktop.addEventListener('change', onChange)
|
||||
return () => {
|
||||
lists.tablet.removeEventListener('change', onChange)
|
||||
lists.desktop.removeEventListener('change', onChange)
|
||||
}
|
||||
},
|
||||
[lists],
|
||||
)
|
||||
return useSyncExternalStore(subscribe, () =>
|
||||
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/* App-wide base styles. Values come only from tokens.css. */
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white".
|
||||
Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */
|
||||
/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside)
|
||||
in place of the handoff's Google Fonts @import, so the app never calls Google.
|
||||
Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets.
|
||||
Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */
|
||||
@font-face {
|
||||
font-family: "Bagel Fat One";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bagel Fat One";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Ground + ink */
|
||||
--color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */
|
||||
--color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */
|
||||
--color-text: #201e1d;
|
||||
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
|
||||
--on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */
|
||||
|
||||
/* Primary accent — Grape (hue 300) */
|
||||
--color-accent: #5a358c; /* oklch(0.42 0.14 300) */
|
||||
--color-accent-100: #f8f1ff;
|
||||
--color-accent-200: #eedfff;
|
||||
--color-accent-300: #dac3ff;
|
||||
--color-accent-400: #c1a1f9;
|
||||
--color-accent-500: #a47ee1;
|
||||
--color-accent-600: #865fc0;
|
||||
--color-accent-700: #68449b; /* accent-colored small text on light grounds */
|
||||
--color-accent-800: #4a2d73;
|
||||
--color-accent-900: #2d1a49;
|
||||
|
||||
/* Second accent — Teal blue (hue 215) */
|
||||
--color-accent-2: #29889c; /* oklch(0.58 0.09 215) */
|
||||
--color-accent-2-100: #e0fbff;
|
||||
--color-accent-2-200: #bbf3ff;
|
||||
--color-accent-2-300: #88e0f6;
|
||||
--color-accent-2-400: #3fc7e4;
|
||||
--color-accent-2-500: #00a9ca;
|
||||
--color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */
|
||||
--color-accent-2-700: #006b88;
|
||||
--color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */
|
||||
--color-accent-2-900: #002f3e;
|
||||
|
||||
/* Neutrals (unchanged from Organic) */
|
||||
--color-neutral-100: #f9f4ed;
|
||||
--color-neutral-200: #eee7db;
|
||||
--color-neutral-300: #dcd3c4;
|
||||
--color-neutral-400: #c0b6a5;
|
||||
--color-neutral-500: #a19786;
|
||||
--color-neutral-600: #82796a;
|
||||
--color-neutral-700: #645c50;
|
||||
--color-neutral-800: #474238;
|
||||
--color-neutral-900: #2e2b25;
|
||||
|
||||
/* Type */
|
||||
--font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */
|
||||
--font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */
|
||||
--font-mono: ui-monospace, Menlo, monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 28px;
|
||||
--radius-card: 32px; /* mobile hero cards */
|
||||
--radius-pill: 999px; /* buttons, inputs, tags, nav */
|
||||
|
||||
/* Spacing (Organic, density 1.1) */
|
||||
--space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
|
||||
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
|
||||
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
|
||||
|
||||
/* Motion */
|
||||
--ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */
|
||||
--ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */
|
||||
--ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root { --motion-scale: 0; }
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
|
||||
// the build's precache list (shell, JS, CSS and fonts, each with a revision).
|
||||
// Every other request goes through the route src/sw/routes.ts decides.
|
||||
// Fonts are precached too, but their route is registered ahead of the
|
||||
// precache route, so a font fetch goes through the expiring CacheFirst
|
||||
// strategy and falls back to the precached copy when the network fails.
|
||||
|
||||
import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { CacheFirst, NetworkFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import {
|
||||
cacheNames,
|
||||
cacheVersion,
|
||||
networkTimeoutSeconds,
|
||||
routeFor,
|
||||
staleCaches,
|
||||
type Route,
|
||||
} from './sw/routes'
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
|
||||
}
|
||||
|
||||
// The precache becomes mealquest-precache-v1.
|
||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
||||
|
||||
const day = 24 * 60 * 60
|
||||
|
||||
const strategies = {
|
||||
[cacheNames.api]: new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds }),
|
||||
[cacheNames.media]: new CacheFirst({
|
||||
cacheName: cacheNames.media,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }),
|
||||
],
|
||||
}),
|
||||
[cacheNames.fonts]: new CacheFirst({
|
||||
cacheName: cacheNames.fonts,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }),
|
||||
// Offline with no runtime copy yet: the font from the precache.
|
||||
{ handlerDidError: async ({ request }) => matchPrecache(request.url) },
|
||||
],
|
||||
}),
|
||||
}
|
||||
const networkOnly = new NetworkOnly()
|
||||
const navigation = new NetworkOnly({
|
||||
plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })],
|
||||
})
|
||||
|
||||
function strategyFor(route: Route): Strategy {
|
||||
switch (route.strategy) {
|
||||
case 'network-first':
|
||||
case 'cache-first':
|
||||
return strategies[route.cacheName]
|
||||
case 'network-only':
|
||||
return networkOnly
|
||||
case 'navigation-fallback':
|
||||
return navigation
|
||||
}
|
||||
}
|
||||
|
||||
function routeOf({ request, url }: { request: Request; url: URL }): Route {
|
||||
return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin)
|
||||
}
|
||||
|
||||
function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise<Response> {
|
||||
return strategyFor(routeOf({ request, url })).handle({ request, event })
|
||||
}
|
||||
|
||||
function isFont(options: { request: Request; url: URL }): boolean {
|
||||
const route = routeOf(options)
|
||||
return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts
|
||||
}
|
||||
|
||||
// Workbox uses the first route that matches, so the font route comes before
|
||||
// the precache route, which would otherwise answer every precached font.
|
||||
registerRoute(isFont, handle, 'GET')
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// Workbox routes are per method; every method goes through routeFor, which
|
||||
// sends every non-GET to the network only.
|
||||
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) {
|
||||
registerRoute(() => true, handle, method)
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
const current = [workboxCacheNames.precache, ...Object.values(cacheNames)]
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))),
|
||||
)
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cacheNames, routeFor, staleCaches, type Route } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route {
|
||||
return routeFor({ method, url: new URL(path, origin), mode }, origin)
|
||||
}
|
||||
|
||||
const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
describe('routeFor', () => {
|
||||
it.each([
|
||||
'/api/v1/plans',
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners',
|
||||
'/api/v1/dinners/01J9ZK?include=recipe',
|
||||
'/api/v1/home',
|
||||
])('GET %s is network-first in the API cache', (path) => {
|
||||
expect(route(path)).toEqual(apiCache)
|
||||
})
|
||||
|
||||
it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])(
|
||||
'GET %s is cache-first in the media cache',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media })
|
||||
},
|
||||
)
|
||||
|
||||
it('GET of a font file is cache-first in the fonts cache', () => {
|
||||
expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({
|
||||
strategy: 'cache-first',
|
||||
cacheName: cacheNames.fonts,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/login',
|
||||
'/auth/callback?code=x&state=y',
|
||||
'/auth/logout',
|
||||
'/auth/me',
|
||||
'/api/v1/settings',
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/jobs',
|
||||
'/api/v1/jobs/01J9ZK',
|
||||
])('GET %s is network-only', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])(
|
||||
'a navigation to %s is network-only with no offline fallback',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'/%61uth/login',
|
||||
'/auth%2Fcallback?code=x&state=y',
|
||||
'/%61pi/v1/settings/household',
|
||||
'/api/v1/%73ettings/ai',
|
||||
'/api/v1/settings%2Fai',
|
||||
'/%61pi/v1/jobs/01J9ZK',
|
||||
'/api/v1/j%6Fbs',
|
||||
'/api/v1/plans%2F..%2Fsettings',
|
||||
'/plan%2F..%2Fauth%2Flogin',
|
||||
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])(
|
||||
'a malformed encoding in %s is network-only, navigated or not',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it('classifies other API paths by their decoded form', () => {
|
||||
expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache)
|
||||
expect(route('/%61pi/v1/recipes', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
|
||||
'%s is network-only on every URL class',
|
||||
(method) => {
|
||||
for (const path of [
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners/01J9ZK',
|
||||
'/api/v1/home',
|
||||
'/media/photos/abc.jpg',
|
||||
'/assets/Figtree-Bold-a1b2c3.woff2',
|
||||
'/auth/logout',
|
||||
'/plan',
|
||||
]) {
|
||||
expect(route(path, method), `${method} ${path}`).toEqual(networkOnly)
|
||||
expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('matches whole path segments only', () => {
|
||||
expect(route('/api/v1/plansx')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/homepage')).toEqual(networkOnly)
|
||||
expect(route('/mediax/a.jpg')).toEqual(networkOnly)
|
||||
expect(route('/authors')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/settingsx')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves other API calls network-only', () => {
|
||||
expect(route('/api/v1/recipes')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves cross-origin requests network-only', () => {
|
||||
for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) {
|
||||
const url = new URL(path, 'https://cdn.example')
|
||||
expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])(
|
||||
'a navigation to the app at %s falls back to the precached index.html',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' })
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves other same-origin GETs network-only', () => {
|
||||
expect(route('/health')).toEqual(networkOnly)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache names', () => {
|
||||
it('all start with mealquest-', () => {
|
||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleCaches', () => {
|
||||
it('deletes only MealQuest caches that are not current', () => {
|
||||
const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
|
||||
const names = [
|
||||
...current,
|
||||
'mealquest-precache',
|
||||
'mealquest-api-v0',
|
||||
'workbox-precache-v2-https://mealquest.test/',
|
||||
'other-app-cache',
|
||||
'my-mealquest-cache',
|
||||
]
|
||||
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
|
||||
})
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
// Decides how the service worker handles each request. This module is pure —
|
||||
// no Workbox, no service worker globals — so the tests can assert every URL
|
||||
// class without a worker; src/sw.ts maps each route to a Workbox strategy.
|
||||
//
|
||||
// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every
|
||||
// non-GET request are network-only and never reach a cache. Paths are matched
|
||||
// as the server sees them — percent-decoded, before and after dot segments are
|
||||
// resolved — so /%61uth/login is /auth/login; a malformed encoding is
|
||||
// network-only.
|
||||
|
||||
export type Route =
|
||||
// Network with a 3 s timeout, then the last cached response.
|
||||
| { strategy: 'network-first'; cacheName: typeof cacheNames.api }
|
||||
// Cached response when there is one, else the network; entries expire.
|
||||
| { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts }
|
||||
// Network only, nothing cached.
|
||||
| { strategy: 'network-only' }
|
||||
// Network only, nothing cached; offline, the precached index.html.
|
||||
| { strategy: 'navigation-fallback' }
|
||||
|
||||
export interface RouteRequest {
|
||||
method: string
|
||||
url: URL
|
||||
mode: RequestMode
|
||||
}
|
||||
|
||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||
export const cacheVersion = 'v1'
|
||||
export const cachePrefix = 'mealquest-'
|
||||
|
||||
export const cacheNames = {
|
||||
api: `${cachePrefix}api-${cacheVersion}`,
|
||||
media: `${cachePrefix}media-${cacheVersion}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||
} as const
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
|
||||
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/home']
|
||||
|
||||
// True when path is prefix itself or a path below it: /api/v1/plans matches
|
||||
// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx.
|
||||
function under(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`)
|
||||
}
|
||||
|
||||
// The pathname percent-decoded, as the server's router sees it, or undefined
|
||||
// when the encoding is malformed.
|
||||
function decodePath(pathname: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(pathname)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves "." and ".." segments the way the server's path.Clean does, so a
|
||||
// decoded %2F cannot carry a request out of a reserved prefix:
|
||||
// /api/v1/plans/../settings cleans to /api/v1/settings.
|
||||
function cleanPath(path: string): string {
|
||||
const segments: string[] = []
|
||||
for (const segment of path.split('/')) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') segments.pop()
|
||||
else segments.push(segment)
|
||||
}
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
export function routeFor(request: RouteRequest, origin: string): Route {
|
||||
const { method, url, mode } = request
|
||||
if (method !== 'GET') return networkOnly
|
||||
if (url.origin !== origin) return networkOnly
|
||||
const path = decodePath(url.pathname)
|
||||
if (path === undefined) return networkOnly
|
||||
const cleaned = cleanPath(path)
|
||||
const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix)
|
||||
if (neverCached.some(reserved)) return networkOnly
|
||||
if (networkFirst.some((prefix) => under(path, prefix))) {
|
||||
return { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
}
|
||||
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
|
||||
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts }
|
||||
if (reserved('/api')) return networkOnly
|
||||
if (mode === 'navigate') return { strategy: 'navigation-fallback' }
|
||||
return networkOnly
|
||||
}
|
||||
|
||||
// The caches activate deletes: MealQuest caches that are no longer current.
|
||||
// Caches without the mealquest- prefix belong to someone else and are kept.
|
||||
export function staleCaches(names: readonly string[], current: readonly string[]): string[] {
|
||||
return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name))
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// The service worker itself needs worker globals, so these tests read its
|
||||
// source: they pin the parts routes.ts cannot express — the font strategy's
|
||||
// expiration and the font route's place ahead of the precache route.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import source from '../sw.ts?raw'
|
||||
|
||||
describe('service worker source', () => {
|
||||
it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => {
|
||||
expect(source).toMatch(
|
||||
/\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the precached font when the network fails', () => {
|
||||
expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)')
|
||||
})
|
||||
|
||||
it('registers the font route before the precache route', () => {
|
||||
const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')")
|
||||
const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)')
|
||||
expect(fontRoute).toBeGreaterThanOrEqual(0)
|
||||
expect(precache).toBeGreaterThan(fontRoute)
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
|
||||
// (css: true). jsdom's getComputedStyle cannot answer two things these
|
||||
// primitives need checked: it resolves var() colours to transparent, and it
|
||||
// evaluates only "all" and "screen" media queries, so styles inside
|
||||
// @media (prefers-reduced-motion: reduce) never apply.
|
||||
|
||||
function cssRules(): CSSRule[] {
|
||||
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
|
||||
}
|
||||
|
||||
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
|
||||
for (const rule of rules) {
|
||||
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
|
||||
for (const property of Array.from(rule.style)) {
|
||||
style.setProperty(property, rule.style.getPropertyValue(property))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The declarations the stylesheets give the element, as written. Read the
|
||||
// shorthands the CSS uses (background, animation): jsdom does not expand
|
||||
// animation, nor any shorthand holding a var(). Later rules win; specificity
|
||||
// is not considered.
|
||||
export function declaredStyle(element: Element): CSSStyleDeclaration {
|
||||
const style = document.createElement('div').style
|
||||
collect(element, cssRules(), style)
|
||||
return style
|
||||
}
|
||||
|
||||
// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
|
||||
export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
|
||||
const style = document.createElement('div').style
|
||||
for (const rule of cssRules()) {
|
||||
if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
|
||||
collect(element, Array.from(rule.cssRules), style)
|
||||
}
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// The @keyframes the element's animation runs. CSS Modules hash keyframe
|
||||
// names, so this follows the element's own animation-name.
|
||||
export function animationKeyframes(element: Element): CSSKeyframesRule {
|
||||
const [name] = declaredStyle(element).animation.split(' ')
|
||||
const keyframes = cssRules().find(
|
||||
(rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
|
||||
)
|
||||
if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
|
||||
return keyframes
|
||||
}
|
||||
|
||||
function px(value: string, what: string): number {
|
||||
if (value === '') return 0
|
||||
const match = /^(\d+(?:\.\d+)?)px$/.exec(value)
|
||||
if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
// The border-box size the element's declared rules give it on one axis:
|
||||
// width or height, plus padding and border unless box-sizing is border-box.
|
||||
// jsdom does no layout (getBoundingClientRect is all zeros), so this is the
|
||||
// way to catch padding that would push a fixed box past its specified size.
|
||||
export function outerSize(element: Element, axis: 'width' | 'height'): number {
|
||||
const style = declaredStyle(element)
|
||||
const size = px(style.getPropertyValue(axis), axis)
|
||||
if (style.boxSizing === 'border-box') return size
|
||||
const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom']
|
||||
return sides.reduce(
|
||||
(total, side) =>
|
||||
total +
|
||||
px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) +
|
||||
px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`),
|
||||
size,
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import type { ApiLocation, Me } from '../api/index.ts'
|
||||
|
||||
export const ME: Me = {
|
||||
name: 'Sam',
|
||||
email: 'sam@example.com',
|
||||
household: { label: 'Our kitchen' },
|
||||
csrfToken: 'token-1',
|
||||
}
|
||||
|
||||
export function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
export function errorResponse(status: number, code: string, retryable = false): Response {
|
||||
return jsonResponse(status, { error: { code, message: `${code} message`, retryable } })
|
||||
}
|
||||
|
||||
// stubFetch replaces the global fetch with a mock answered from responses in
|
||||
// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals.
|
||||
export function stubFetch(...responses: Response[]): Mock<typeof fetch> {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
for (const response of responses) fetchMock.mockResolvedValueOnce(response)
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// request returns the method, path and headers of the fetch mock's nth call.
|
||||
export function request(fetchMock: Mock<typeof fetch>, n: number) {
|
||||
const call = fetchMock.mock.calls[n]
|
||||
if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`)
|
||||
const [input, init] = call
|
||||
return {
|
||||
path: String(input),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
headers: new Headers(init?.headers),
|
||||
body: init?.body,
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLocation stands in for window.location on https://mealquest.test,
|
||||
// recording navigations.
|
||||
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
|
||||
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { expect } from 'vitest'
|
||||
|
||||
// Minimum tap target from the handoff's accessibility rules.
|
||||
export const MIN_HIT_AREA = 44
|
||||
|
||||
function px(value: string): number {
|
||||
return value.endsWith('px') ? Number.parseFloat(value) : 0
|
||||
}
|
||||
|
||||
// The tap target an element's stylesheet guarantees: the larger of its
|
||||
// declared size and declared minimum size, per axis. jsdom does no layout, so
|
||||
// this reads the CSS Modules' computed values (Vitest runs with css: true).
|
||||
// A dimension the CSS leaves unsized counts as 0 and fails the check.
|
||||
export function hitArea(element: Element): { width: number; height: number } {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
width: Math.max(px(style.width), px(style.minWidth)),
|
||||
height: Math.max(px(style.height), px(style.minHeight)),
|
||||
}
|
||||
}
|
||||
|
||||
export function expectMinHitArea(element: Element) {
|
||||
const { width, height } = hitArea(element)
|
||||
expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA)
|
||||
expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { act } from '@testing-library/react'
|
||||
|
||||
type Listener = (event: MediaQueryListEvent) => void
|
||||
|
||||
// Replaces window.matchMedia with one that answers min-width / max-width
|
||||
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
|
||||
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
|
||||
// function that resizes the pretend viewport and notifies every query whose
|
||||
// answer changed.
|
||||
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
|
||||
let width = initialWidth
|
||||
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
|
||||
|
||||
function evaluate(query: string): boolean {
|
||||
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
|
||||
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
|
||||
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
|
||||
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
|
||||
return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1]))
|
||||
}
|
||||
|
||||
window.matchMedia = (query: string) => {
|
||||
const listeners = new Set<Listener>()
|
||||
const list = {
|
||||
media: query,
|
||||
get matches() {
|
||||
return evaluate(query)
|
||||
},
|
||||
onchange: null,
|
||||
addEventListener: (_type: string, listener: Listener) => listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener),
|
||||
addListener: (listener: Listener) => listeners.add(listener),
|
||||
removeListener: (listener: Listener) => listeners.delete(listener),
|
||||
dispatchEvent: () => true,
|
||||
} as unknown as MediaQueryList
|
||||
lists.push({ list, listeners })
|
||||
return list
|
||||
}
|
||||
|
||||
return (next: number) => {
|
||||
const before = lists.map(({ list }) => list.matches)
|
||||
width = next
|
||||
act(() => {
|
||||
lists.forEach(({ list, listeners }, i) => {
|
||||
if (list.matches === before[i]) return
|
||||
const event = { matches: list.matches, media: list.media } as MediaQueryListEvent
|
||||
listeners.forEach((listener) => listener(event))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 44px;
|
||||
min-height: 48px;
|
||||
padding: 0 22px;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled),
|
||||
.primary:active:not(:disabled) {
|
||||
background: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.outline {
|
||||
background: transparent;
|
||||
border-color: var(--color-neutral-300);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.outline:hover:not(:disabled),
|
||||
.outline:active:not(:disabled) {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.dark {
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.dark:hover:not(:disabled),
|
||||
.dark:active:not(:disabled) {
|
||||
background: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Button } from './Button.tsx'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
describe('Button', () => {
|
||||
it('is a button that defaults to type="button" and the primary variant', () => {
|
||||
render(<Button>Save changes</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Save changes' })
|
||||
expect(button).toHaveAttribute('type', 'button')
|
||||
expect(button).toHaveClass(styles.button!, styles.primary!)
|
||||
})
|
||||
|
||||
it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => {
|
||||
render(<Button variant={variant}>Go</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Go' })
|
||||
expect(button).toHaveClass(styles[variant]!)
|
||||
expectMinHitArea(button)
|
||||
})
|
||||
|
||||
it('declares the 100 ms pressed scale transition', () => {
|
||||
render(<Button>Go</Button>)
|
||||
expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms')
|
||||
})
|
||||
|
||||
it('does not fire onClick when disabled', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Go
|
||||
</Button>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Go' })
|
||||
expect(button).toBeDisabled()
|
||||
fireEvent.click(button)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards the ref, rest props and an explicit type', () => {
|
||||
const ref = createRef<HTMLButtonElement>()
|
||||
render(
|
||||
<Button ref={ref} type="submit" aria-describedby="hint">
|
||||
Send
|
||||
</Button>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Send' })
|
||||
expect(ref.current).toBe(button)
|
||||
expect(button).toHaveAttribute('type', 'submit')
|
||||
expect(button).toHaveAttribute('aria-describedby', 'hint')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'outline' | 'dark'
|
||||
|
||||
export type ButtonProps = ComponentPropsWithRef<'button'> & {
|
||||
variant?: ButtonVariant
|
||||
}
|
||||
|
||||
// Pill button. Primary is grape with --on-accent text; outline and dark are the
|
||||
// secondary treatments from the handoff. `type` defaults to "button" so a
|
||||
// Button inside a form never submits by accident.
|
||||
export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) {
|
||||
const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ')
|
||||
return <button type={type} className={classes} {...rest} />
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.muted {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Card } from './Card.tsx'
|
||||
import styles from './Card.module.css'
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders its children on the surface fill by default', () => {
|
||||
render(<Card aria-label="Tonight">Tacos</Card>)
|
||||
const card = screen.getByLabelText('Tonight')
|
||||
expect(card).toHaveTextContent('Tacos')
|
||||
expect(card).toHaveClass(styles.card!, styles.surface!)
|
||||
})
|
||||
|
||||
it('supports the muted tone', () => {
|
||||
render(<Card data-testid="card" tone="muted" />)
|
||||
expect(screen.getByTestId('card')).toHaveClass(styles.muted!)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user