Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3e7d0e45 | ||
|
|
0fa9cfe611 | ||
|
|
1dd97b123c | ||
|
|
02f1dd6491 | ||
|
|
496f2e5375 | ||
|
|
7cc96121e4 | ||
|
|
bba9033ac5 | ||
|
|
e050736178 |
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job:
|
||||
// a noop job enqueued before Start, as at boot, succeeds with its result.
|
||||
func TestNewJobRunnerRunsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
stopJobs()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
job, err := runner.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||
t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("noop job never finished; last %+v", job)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
+50
-1
@@ -18,6 +18,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -27,6 +28,9 @@ import (
|
||||
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||
var version = "dev"
|
||||
|
||||
// jobWorkers is the number of background jobs run at once.
|
||||
const jobWorkers = 2
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -63,6 +67,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
settings := household.NewSettingsService(db)
|
||||
runner, _, err := newJobRunner(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
@@ -80,10 +88,22 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The workers stop, and are waited for, before the
|
||||
// database closes.
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
}()
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
@@ -165,6 +185,35 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
|
||||
return household.SeedNotificationPrefs(ctx, db)
|
||||
}
|
||||
|
||||
// jobKinds enqueues every job kind the server runs.
|
||||
type jobKinds struct {
|
||||
builtins *jobs.Builtins
|
||||
}
|
||||
|
||||
// newJobRunner returns the job runner with every job kind registered, ready
|
||||
// to Start.
|
||||
func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) {
|
||||
runner, err := jobs.NewRunner(db, logger, jobWorkers)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
kinds, err := registerJobKinds(runner)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return runner, kinds, nil
|
||||
}
|
||||
|
||||
// registerJobKinds registers every job kind's handler on runner. Each
|
||||
// feature's job kinds are registered here.
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &jobKinds{builtins: builtins}, nil
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: jobs.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const claimNextJob = `-- name: ClaimNextJob :one
|
||||
UPDATE jobs
|
||||
SET status = 'running', started_at = ?1, updated_at = ?1
|
||||
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
|
||||
`
|
||||
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
|
||||
row := q.db.QueryRowContext(ctx, claimNextJob, now)
|
||||
var i Job
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Status,
|
||||
&i.Payload,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createJob = `-- name: CreateJob :exec
|
||||
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateJobParams struct {
|
||||
ID string
|
||||
Kind string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createJob,
|
||||
arg.ID,
|
||||
arg.Kind,
|
||||
arg.Payload,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const failJob = `-- name: FailJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'running'
|
||||
`
|
||||
|
||||
type FailJobParams struct {
|
||||
Error sql.NullString
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const failRunningJobs = `-- name: FailRunningJobs :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE status = 'running'
|
||||
`
|
||||
|
||||
type FailRunningJobsParams struct {
|
||||
Error sql.NullString
|
||||
Now sql.NullString
|
||||
}
|
||||
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getJob = `-- name: GetJob :one
|
||||
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
|
||||
row := q.db.QueryRowContext(ctx, getJob, id)
|
||||
var i Job
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Status,
|
||||
&i.Payload,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const publishJobResult = `-- name: PublishJobResult :execrows
|
||||
UPDATE jobs
|
||||
SET result = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
`
|
||||
|
||||
type PublishJobResultParams struct {
|
||||
Result sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Stores a partial result of a running job.
|
||||
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const succeedJob = `-- name: SucceedJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'running'
|
||||
`
|
||||
|
||||
type SucceedJobParams struct {
|
||||
Result sql.NullString
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -6,17 +6,25 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
@@ -26,6 +34,7 @@ type Querier interface {
|
||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
|
||||
GetJob(ctx context.Context, id string) (Job, error)
|
||||
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||
// unique, so the earliest-created (lowest id) match is returned.
|
||||
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
|
||||
@@ -36,8 +45,11 @@ type Querier interface {
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// KindNoop is the framework's built-in job kind: it does no work and
|
||||
// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature
|
||||
// 09's trivial test skill is the harness's, and the startup and readiness
|
||||
// job: a noop job reaching succeeded proves the runner claims, runs and
|
||||
// records jobs.
|
||||
const KindNoop = "noop"
|
||||
|
||||
// NoopPayload is the payload of a noop job.
|
||||
type NoopPayload struct{}
|
||||
|
||||
// NoopResult is the result of a noop job.
|
||||
type NoopResult struct{}
|
||||
|
||||
// PublicJSON returns {"ok":true}.
|
||||
func (NoopResult) PublicJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
OK bool `json:"ok"`
|
||||
}{OK: true})
|
||||
}
|
||||
|
||||
func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) {
|
||||
return NoopResult{}, nil
|
||||
}
|
||||
|
||||
// Builtins enqueues the framework's built-in job kinds.
|
||||
type Builtins struct {
|
||||
Noop *Kind[NoopPayload]
|
||||
}
|
||||
|
||||
// RegisterBuiltins registers the framework's built-in job kinds on r.
|
||||
func RegisterBuiltins(r *Runner) (*Builtins, error) {
|
||||
noop, err := Register(r, KindNoop, runNoop)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Builtins{Noop: noop}, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// Package jobs runs long work in the background. A job is a row in the jobs
|
||||
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
|
||||
// jobs oldest first, so the table itself is the queue and a job enqueued
|
||||
// before a restart still runs after it.
|
||||
//
|
||||
// Each job kind has one handler, registered before Start. A handler takes a
|
||||
// typed payload and returns a result implementing Public; it may publish
|
||||
// partial results while it runs. The runner stores only what a result's
|
||||
// PublicJSON returns and, for a failed job, only a failure code: either a
|
||||
// *Failure code the kind declared at registration or one of the runner's own
|
||||
// codes. Handler error text, which may quote a URL, token or response body
|
||||
// from a dependency, is never stored, returned by the API or logged.
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Status is the state of a job.
|
||||
type Status string
|
||||
|
||||
// Job states. A job moves queued → running → succeeded or failed.
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusRunning Status = "running"
|
||||
StatusSucceeded Status = "succeeded"
|
||||
StatusFailed Status = "failed"
|
||||
)
|
||||
|
||||
// Failure codes the runner stores itself. A kind cannot declare them.
|
||||
const (
|
||||
// CodeInterrupted is the error of a job a previous process left running.
|
||||
// It is fixed framework text, not a snake-case code, so no kind can
|
||||
// declare it.
|
||||
CodeInterrupted = "interrupted by restart"
|
||||
// CodeInternal is the error of a job whose handler failed with anything
|
||||
// but a *Failure with a code its kind declared, or whose payload or
|
||||
// result could not be encoded. The underlying error is neither stored
|
||||
// nor logged.
|
||||
CodeInternal = "internal_error"
|
||||
// CodeUnknownKind is the error of a job whose kind has no handler.
|
||||
CodeUnknownKind = "unknown_kind"
|
||||
)
|
||||
|
||||
// Claim retry backoff. A worker whose claim fails with a database error
|
||||
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
|
||||
// when the error clears is claimed without waiting for a wake token.
|
||||
const (
|
||||
claimRetryMin = 50 * time.Millisecond
|
||||
claimRetryMax = 5 * time.Second
|
||||
)
|
||||
|
||||
// codePattern is the form of every failure code: lower-case snake case.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
// ErrNotFound is returned by Get for an unknown job id.
|
||||
var ErrNotFound = errors.New("jobs: job not found")
|
||||
|
||||
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
|
||||
// registered handler.
|
||||
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||
|
||||
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||
// not decode into the kind's payload type.
|
||||
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||
|
||||
// Failure is a handler error reporting a failure code the household may
|
||||
// see. Code must be one the job's kind declared at registration; the runner
|
||||
// stores it as the job's error. Any other handler error, including a Failure
|
||||
// with an undeclared code, is stored as CodeInternal.
|
||||
type Failure struct {
|
||||
Code string
|
||||
}
|
||||
|
||||
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
|
||||
|
||||
// Public is a job result. PublicJSON returns the JSON the runner stores and
|
||||
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
|
||||
// data is public; nothing else of the value is ever encoded.
|
||||
type Public interface {
|
||||
PublicJSON() ([]byte, error)
|
||||
}
|
||||
|
||||
// Handler runs one job with its payload. It may call publish any number of
|
||||
// times with a partial result, which Get returns while the job runs; its
|
||||
// return value is the final result. ctx is cancelled when the runner stops.
|
||||
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
|
||||
|
||||
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
|
||||
// partial or final result, and is null until the handler publishes one.
|
||||
// Error is set only on a failed job.
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Status Status `json:"status"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
// runFunc is a registered Handler with its payload and results as JSON. It
|
||||
// returns the failure code to store, or "" on success; the logged reason
|
||||
// never quotes an error.
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, and check reports whether
|
||||
// a payload decodes into the kind's payload type.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
type Runner struct {
|
||||
st *store.Store
|
||||
logger *slog.Logger
|
||||
workers int
|
||||
// wake holds up to one token per worker; Enqueue adds one so an idle
|
||||
// worker looks for the new job.
|
||||
wake chan struct{}
|
||||
// claim claims the oldest queued job, marking it running at now.
|
||||
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
||||
|
||||
mu sync.Mutex
|
||||
handlers map[string]handler
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewRunner returns a runner over st with workers concurrent workers.
|
||||
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("jobs: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("jobs: nil logger")
|
||||
}
|
||||
if workers < 1 {
|
||||
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
|
||||
}
|
||||
return &Runner{
|
||||
st: st,
|
||||
logger: logger,
|
||||
workers: workers,
|
||||
wake: make(chan struct{}, workers),
|
||||
claim: db.New(st.Writer).ClaimNextJob,
|
||||
handlers: make(map[string]handler),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Kind enqueues jobs of one registered kind.
|
||||
type Kind[P any] struct {
|
||||
r *Runner
|
||||
name string
|
||||
}
|
||||
|
||||
// Register makes h the handler for kind and returns the Kind that enqueues
|
||||
// its jobs. failureCodes are the *Failure codes h may report. It must be
|
||||
// called before Start, once per kind.
|
||||
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
|
||||
if kind == "" {
|
||||
return nil, errors.New("jobs: register: empty kind")
|
||||
}
|
||||
if h == nil {
|
||||
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
|
||||
}
|
||||
declared := make(map[string]bool, len(failureCodes))
|
||||
for _, code := range failureCodes {
|
||||
if !codePattern.MatchString(code) {
|
||||
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
|
||||
}
|
||||
if code == CodeInternal || code == CodeUnknownKind {
|
||||
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
|
||||
}
|
||||
declared[code] = true
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.started {
|
||||
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
|
||||
}
|
||||
if _, ok := r.handlers[kind]; ok {
|
||||
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
||||
}
|
||||
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
||||
p, err := decodePayload[P]([]byte(payload))
|
||||
if err != nil {
|
||||
return nil, CodeInternal, "payload does not decode"
|
||||
}
|
||||
result, err := h(ctx, p, func(partial R) error {
|
||||
data, err := publicJSON(partial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return publish(data)
|
||||
})
|
||||
if err != nil {
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) {
|
||||
return nil, CodeInternal, "handler error"
|
||||
}
|
||||
if !declared[failure.Code] {
|
||||
return nil, CodeInternal, "undeclared failure code"
|
||||
}
|
||||
return nil, failure.Code, "handler failure"
|
||||
}
|
||||
data, err := publicJSON(result)
|
||||
if err != nil {
|
||||
return nil, CodeInternal, "result does not encode"
|
||||
}
|
||||
return data, "", ""
|
||||
}
|
||||
check := func(payload []byte) bool {
|
||||
_, err := decodePayload[P](payload)
|
||||
return err == nil
|
||||
}
|
||||
r.handlers[kind] = handler{run: run, check: check}
|
||||
return &Kind[P]{r: r, name: kind}, nil
|
||||
}
|
||||
|
||||
// decodePayload decodes payload into P, refusing unknown fields.
|
||||
func decodePayload[P any](payload []byte) (P, error) {
|
||||
var p P
|
||||
dec := json.NewDecoder(bytes.NewReader(payload))
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(&p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
||||
// value. Its error never quotes PublicJSON's error.
|
||||
func publicJSON(v Public) (json.RawMessage, error) {
|
||||
data, err := v.PublicJSON()
|
||||
if err != nil || !json.Valid(data) {
|
||||
return nil, errors.New("jobs: result has no valid public JSON")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Enqueue stores a queued job with payload and returns its id. A job
|
||||
// enqueued before Start waits for it.
|
||||
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||
}
|
||||
return k.r.enqueue(ctx, k.name, data)
|
||||
}
|
||||
|
||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||
// ErrUnknownKind for a kind with no registered handler and
|
||||
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||
// payload type; neither stores a job.
|
||||
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||
r.mu.Lock()
|
||||
h, ok := r.handlers[kind]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
return "", ErrUnknownKind
|
||||
}
|
||||
if !h.check(payload) {
|
||||
return "", ErrInvalidPayload
|
||||
}
|
||||
return r.enqueue(ctx, kind, payload)
|
||||
}
|
||||
|
||||
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||
// worker.
|
||||
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Payload: string(payload),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||
}
|
||||
select {
|
||||
case r.wake <- struct{}{}:
|
||||
default: // every worker already has a token and will look again
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Start fails every job a previous process left running with
|
||||
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
|
||||
// Wait waits for them. A job whose handler returns an error after ctx is
|
||||
// cancelled is left running, so the next Start fails it as interrupted.
|
||||
func (r *Runner) Start(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.started {
|
||||
return errors.New("jobs: runner already started")
|
||||
}
|
||||
now := store.Now().String()
|
||||
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
|
||||
Error: sql.NullString{String: CodeInterrupted, Valid: true},
|
||||
Now: sql.NullString{String: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
|
||||
}
|
||||
r.started = true
|
||||
for range r.workers {
|
||||
r.wg.Add(1)
|
||||
go r.work(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until every worker has stopped.
|
||||
func (r *Runner) Wait() {
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
// Get returns the job with id, or ErrNotFound.
|
||||
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Job{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Job{}, fmt.Errorf("jobs: read job: %w", err)
|
||||
}
|
||||
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
|
||||
if row.Result.Valid {
|
||||
job.Result = json.RawMessage(row.Result.String)
|
||||
}
|
||||
if row.Error.Valid {
|
||||
job.Error = &row.Error.String
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// work claims and runs queued jobs until ctx is cancelled, waiting for a
|
||||
// wake token whenever none is queued. A claim that fails with a database
|
||||
// error is retried with backoff rather than waiting for a token, so a job
|
||||
// already queued is not stranded by a transient error.
|
||||
func (r *Runner) work(ctx context.Context) {
|
||||
defer r.wg.Done()
|
||||
backoff := claimRetryMin
|
||||
for ctx.Err() == nil {
|
||||
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
|
||||
switch {
|
||||
case err == nil:
|
||||
backoff = claimRetryMin
|
||||
r.run(ctx, job)
|
||||
continue
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
backoff = claimRetryMin
|
||||
select {
|
||||
case <-r.wake:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
continue
|
||||
case ctx.Err() != nil:
|
||||
return
|
||||
}
|
||||
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
}
|
||||
backoff = min(2*backoff, claimRetryMax)
|
||||
}
|
||||
}
|
||||
|
||||
// run runs a claimed job to completion. The final write is made even when
|
||||
// ctx has been cancelled, so a handler that finishes during shutdown is
|
||||
// recorded.
|
||||
func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
write := context.WithoutCancel(ctx)
|
||||
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
|
||||
q := db.New(r.st.Writer)
|
||||
|
||||
h, ok := r.handlers[job.Kind] // read-only once started
|
||||
if !ok {
|
||||
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
|
||||
r.fail(write, logger, job.ID, CodeUnknownKind)
|
||||
return
|
||||
}
|
||||
|
||||
publish := func(partial json.RawMessage) error {
|
||||
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
|
||||
Result: sql.NullString{String: string(partial), Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: job.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("jobs: publish result: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||
if code != "" {
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||
return
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
if code == CodeInternal {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
|
||||
r.fail(write, logger, job.ID, code)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := q.SucceedJob(write, db.SucceedJobParams{
|
||||
Result: sql.NullString{String: string(result), Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: job.ID,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(write, "record job success", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fail records the job as failed with code. The record errors it logs come
|
||||
// from the database, never from a handler.
|
||||
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
|
||||
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
|
||||
Error: sql.NullString{String: code, Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: id,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "record job failure", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// sentinelSecret stands in for a credential a handler holds while it runs.
|
||||
const sentinelSecret = "sk-sentinel-5f2c9a"
|
||||
|
||||
type countPayload struct {
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
type countResult struct {
|
||||
Done int `json:"done"`
|
||||
}
|
||||
|
||||
func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) }
|
||||
|
||||
func migratedStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func newRunner(t *testing.T, st *store.Store, workers int) *Runner {
|
||||
t.Helper()
|
||||
return newRunnerLogging(t, st, workers, io.Discard)
|
||||
}
|
||||
|
||||
func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner {
|
||||
t.Helper()
|
||||
r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// logBuffer collects log output written by concurrent workers.
|
||||
type logBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *logBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// start starts r and stops it when the test ends. The returned function
|
||||
// stops it early and waits for its workers.
|
||||
func start(t *testing.T, r *Runner) (stop func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := r.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop = func() {
|
||||
cancel()
|
||||
r.Wait()
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
return stop
|
||||
}
|
||||
|
||||
func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] {
|
||||
t.Helper()
|
||||
k, err := Register(r, kind, h, failureCodes...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func enqueue[P any](t *testing.T, k *Kind[P], payload P) string {
|
||||
t.Helper()
|
||||
id, err := k.Enqueue(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// waitFor polls Get until done accepts the job, failing after five seconds.
|
||||
func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
job, err := r.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done(job) {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed }
|
||||
|
||||
// rawRow returns every column of the job's row joined, for leak checks.
|
||||
func rawRow(t *testing.T, st *store.Store, id string) string {
|
||||
t.Helper()
|
||||
var cols [10]any
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range cols {
|
||||
ptrs[i] = &cols[i]
|
||||
}
|
||||
if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fmt.Sprint(cols[:]...)
|
||||
}
|
||||
|
||||
func TestJobSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 2)
|
||||
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
id := enqueue(t, count, countPayload{Steps: 3})
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil {
|
||||
t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id)
|
||||
}
|
||||
if string(job.Result) != `{"done":3}` {
|
||||
t.Errorf("result = %s, want {\"done\":3}", job.Result)
|
||||
}
|
||||
|
||||
var startedAt, finishedAt, createdAt, updatedAt, payload string
|
||||
if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id).
|
||||
Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(v); err != nil {
|
||||
t.Errorf("%s = %q: %v", name, v, err)
|
||||
}
|
||||
}
|
||||
if payload != `{"steps":3}` {
|
||||
t.Errorf("payload = %s, want {\"steps\":3}", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobFailsWithFailureCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"})
|
||||
}, "no_regulars")
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" {
|
||||
t.Fatalf("job = %+v, want failed with code no_regulars", job)
|
||||
}
|
||||
if job.Result != nil {
|
||||
t.Errorf("result = %s, want none", job.Result)
|
||||
}
|
||||
var stored string
|
||||
if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != "no_regulars" {
|
||||
t.Errorf("stored error = %q, want only the code", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeclaredFailureCodeIsInternal(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, &Failure{Code: "not_declared"}
|
||||
}, "no_regulars")
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal {
|
||||
t.Fatalf("job = %+v, want failed with %q", job, CodeInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// credential holds a secret the way config.Secret does: unexported, so
|
||||
// encoding/json cannot see it.
|
||||
type credential struct{ value string }
|
||||
|
||||
func (credential) String() string { return "[REDACTED]" }
|
||||
|
||||
// leakyResult holds a secret in exported and unexported fields; only
|
||||
// Dinners is public.
|
||||
type leakyResult struct {
|
||||
Dinners []string
|
||||
Key credential
|
||||
Token string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func (l leakyResult) PublicJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Dinners []string `json:"dinners"`
|
||||
}{l.Dinners})
|
||||
}
|
||||
|
||||
// badResult has no valid public JSON; its encoding error quotes a secret.
|
||||
type badResult struct{}
|
||||
|
||||
func (badResult) PublicJSON() ([]byte, error) {
|
||||
return nil, fmt.Errorf("encode with key %s", sentinelSecret)
|
||||
}
|
||||
|
||||
// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot
|
||||
// surface it in the jobs row, Get's result or the log through its result,
|
||||
// partial results, *Failure, error text or result encoding error.
|
||||
func TestSecretsNeverStoredOrLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
var logs logBuffer
|
||||
r := newRunnerLogging(t, st, 1, &logs)
|
||||
secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret}
|
||||
typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) {
|
||||
if err := publish(secretResult); err != nil {
|
||||
return leakyResult{}, err
|
||||
}
|
||||
return secretResult, nil
|
||||
})
|
||||
leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret)
|
||||
})
|
||||
leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, &Failure{Code: sentinelSecret}
|
||||
}, "quota_exceeded")
|
||||
failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"})
|
||||
}, "quota_exceeded")
|
||||
badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) {
|
||||
return badResult{}, nil
|
||||
})
|
||||
stop := start(t, r)
|
||||
|
||||
cases := []struct {
|
||||
kind *Kind[countPayload]
|
||||
wantStatus Status
|
||||
wantResult string
|
||||
wantError string
|
||||
}{
|
||||
{typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""},
|
||||
{leakyError, StatusFailed, "", CodeInternal},
|
||||
{leakyFailure, StatusFailed, "", CodeInternal},
|
||||
{failure, StatusFailed, "", "quota_exceeded"},
|
||||
{badEncoding, StatusFailed, "", CodeInternal},
|
||||
}
|
||||
var ids []string
|
||||
for _, c := range cases {
|
||||
job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished)
|
||||
ids = append(ids, job.ID)
|
||||
gotError := ""
|
||||
if job.Error != nil {
|
||||
gotError = *job.Error
|
||||
}
|
||||
if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError {
|
||||
t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError)
|
||||
}
|
||||
body, err := json.Marshal(job)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), sentinelSecret) {
|
||||
t.Errorf("%s job as served contains the secret: %s", job.Kind, body)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
for _, id := range ids {
|
||||
if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) {
|
||||
t.Errorf("job %s row contains the secret: %s", id, row)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs.String(), sentinelSecret) {
|
||||
t.Errorf("log contains the secret:\n%s", logs.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "code="+CodeInternal) {
|
||||
t.Errorf("log does not record the internal_error code:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressiveResultsVisibleMidRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
release := make(chan struct{})
|
||||
k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
for i := 1; i < p.Steps; i++ {
|
||||
if err := publish(countResult{Done: i}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
return countResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
for step := 1; step < 3; step++ {
|
||||
want := fmt.Sprintf(`{"done":%d}`, step)
|
||||
job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want })
|
||||
if job.Status != StatusRunning || job.Error != nil {
|
||||
t.Fatalf("mid-run job = %+v, want running with no error", job)
|
||||
}
|
||||
release <- struct{}{}
|
||||
}
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
|
||||
first := newRunner(t, st, 1)
|
||||
started := make(chan struct{})
|
||||
k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) {
|
||||
if err := publish(countResult{Done: 1}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return countResult{}, ctx.Err()
|
||||
})
|
||||
stop := start(t, first)
|
||||
interrupted := enqueue(t, k, countPayload{})
|
||||
<-started
|
||||
waiting := enqueue(t, k, countPayload{}) // the only worker is busy
|
||||
stop()
|
||||
|
||||
if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning {
|
||||
t.Fatalf("after shutdown job = %+v, want left running", job)
|
||||
}
|
||||
|
||||
second := newRunner(t, st, 1)
|
||||
register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: 9}, nil
|
||||
})
|
||||
start(t, second)
|
||||
|
||||
job := waitFor(t, second, interrupted, finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" {
|
||||
t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart")
|
||||
}
|
||||
if string(job.Result) != `{"done":1}` {
|
||||
t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result)
|
||||
}
|
||||
if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("queued job = %+v, want run by the new runner", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPoolIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 2)
|
||||
var running, peak atomic.Int32
|
||||
release := make(chan struct{})
|
||||
k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
n := running.Add(1)
|
||||
for {
|
||||
p := peak.Load()
|
||||
if n <= p || peak.CompareAndSwap(p, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
<-release
|
||||
running.Add(-1)
|
||||
return countResult{}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
ids := make([]string, 5)
|
||||
for i := range ids {
|
||||
ids[i] = enqueue(t, k, countPayload{})
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for running.Load() < 2 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("two workers never ran at once")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
for _, id := range ids {
|
||||
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("job = %+v, want succeeded", job)
|
||||
}
|
||||
}
|
||||
if got := peak.Load(); got != 2 {
|
||||
t.Errorf("peak concurrent jobs = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobWithoutHandlerFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
old := newRunner(t, st, 1)
|
||||
k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
})
|
||||
id := enqueue(t, k, countPayload{}) // never started
|
||||
|
||||
r := newRunner(t, st, 1)
|
||||
start(t, r)
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind {
|
||||
t.Errorf("job = %+v, want failed for a missing handler", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnknownID(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Get unknown id error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRefusesBadRegistrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
h := func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
}
|
||||
register(t, r, "count", h)
|
||||
|
||||
if _, err := Register(r, "count", h); err == nil {
|
||||
t.Error("duplicate kind registered")
|
||||
}
|
||||
if _, err := Register(r, "", h); err == nil {
|
||||
t.Error("empty kind registered")
|
||||
}
|
||||
if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil {
|
||||
t.Error("nil handler registered")
|
||||
}
|
||||
for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} {
|
||||
if _, err := Register(r, "code", h, code); err == nil {
|
||||
t.Errorf("failure code %q accepted", code)
|
||||
}
|
||||
}
|
||||
start(t, r)
|
||||
if _, err := Register(r, "late", h); err == nil {
|
||||
t.Error("kind registered after Start")
|
||||
}
|
||||
if err := r.Start(context.Background()); err == nil {
|
||||
t.Error("second Start succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunnerRefusesBadConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRunner(st, logger, 0); err == nil {
|
||||
t.Error("zero workers accepted")
|
||||
}
|
||||
if _, err := NewRunner(nil, logger, 1); err == nil {
|
||||
t.Error("nil store accepted")
|
||||
}
|
||||
if _, err := NewRunner(st, nil, 1); err == nil {
|
||||
t.Error("nil logger accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
builtins, err := RegisterBuiltins(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished)
|
||||
if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||
t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimErrorIsRetried(t *testing.T) {
|
||||
st := migratedStore(t)
|
||||
logs := &logBuffer{}
|
||||
r := newRunnerLogging(t, st, 1, logs)
|
||||
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
// Drop the wake token, as for a job queued before a restart, so only
|
||||
// the retry can find the job.
|
||||
<-r.wake
|
||||
|
||||
var claims atomic.Int32
|
||||
claim := r.claim
|
||||
r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) {
|
||||
if claims.Add(1) == 1 {
|
||||
return db.Job{}, errors.New("database is locked " + sentinelSecret)
|
||||
}
|
||||
return claim(ctx, now)
|
||||
}
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
if n := claims.Load(); n < 2 {
|
||||
t.Fatalf("claims = %d, want a retry after the error", n)
|
||||
}
|
||||
out := logs.String()
|
||||
if !strings.Contains(out, "claim job failed; retrying") {
|
||||
t.Fatalf("claim error not logged: %s", out)
|
||||
}
|
||||
if strings.Contains(out, sentinelSecret) {
|
||||
t.Fatalf("claim error text logged: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
|
||||
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
|
||||
}
|
||||
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
|
||||
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("refused enqueues stored %d jobs, want 0", n)
|
||||
}
|
||||
|
||||
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// maxJobBody bounds a POST /jobs request body.
|
||||
const maxJobBody = 64 << 10
|
||||
|
||||
// jobRequest is the POST /jobs body. Payload is required; a job kind that
|
||||
// takes no input is sent the empty object {}.
|
||||
type jobRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// jobCreated is the POST /jobs response.
|
||||
type jobCreated struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
||||
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
|
||||
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload)
|
||||
switch {
|
||||
case errors.Is(err, jobs.ErrUnknownKind):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
||||
return
|
||||
case errors.Is(err, jobs.ErrInvalidPayload):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJobRequest reads a single JSON object with a non-empty kind and a
|
||||
// payload, refusing unknown fields, an omitted or null payload and trailing
|
||||
// data.
|
||||
func decodeJobRequest(body io.Reader) (jobRequest, error) {
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||
}
|
||||
return jobRequest{}, errors.New("cannot read request body")
|
||||
}
|
||||
var req jobRequest
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return jobRequest{}, errors.New("request body must be a JSON object with kind and payload")
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return jobRequest{}, errors.New("request body must be a single JSON object")
|
||||
}
|
||||
if req.Kind == "" {
|
||||
return jobRequest{}, errors.New("kind: required")
|
||||
}
|
||||
if req.Payload == nil {
|
||||
return jobRequest{}, errors.New("payload: required")
|
||||
}
|
||||
if bytes.Equal(req.Payload, []byte("null")) {
|
||||
return jobRequest{}, errors.New("payload: must not be null")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
|
||||
// result and error.
|
||||
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
job, err := runner.Get(r.Context(), chi.URLParam(r, "id"))
|
||||
if errors.Is(err, jobs.ErrNotFound) {
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get job", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
type draftPayload struct{}
|
||||
|
||||
type draftResult struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) }
|
||||
|
||||
// startedRunner registers a "draft" kind that publishes {"days":1}, waits
|
||||
// for release and returns {"days":7}, or fails with a *jobs.Failure when
|
||||
// fail is closed first. It starts the runner and stops it when the test ends.
|
||||
func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) {
|
||||
t.Helper()
|
||||
runner := newTestRunner(t)
|
||||
kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) {
|
||||
if err := publish(draftResult{Days: 1}); err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
return draftResult{Days: 7}, nil
|
||||
case <-fail:
|
||||
return draftResult{}, &jobs.Failure{Code: "no_regulars"}
|
||||
case <-ctx.Done():
|
||||
return draftResult{}, ctx.Err()
|
||||
}
|
||||
}, "no_regulars")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
return runner, kind
|
||||
}
|
||||
|
||||
// pollJob GETs the job until want accepts the body, failing after five
|
||||
// seconds.
|
||||
func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want(body) {
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) {
|
||||
t.Errorf("body fields = %v, want id, kind, status, result, error", keys)
|
||||
}
|
||||
return body
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never reached the expected state; last %v", id, body)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func resultDays(body map[string]any) float64 {
|
||||
result, _ := body["result"].(map[string]any)
|
||||
days, _ := result["days"].(float64)
|
||||
return days
|
||||
}
|
||||
|
||||
func TestGetJobShowsProgressThenResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, kind := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobShowsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fail := make(chan struct{})
|
||||
close(fail)
|
||||
runner, kind := startedRunner(t, nil, fail)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" })
|
||||
if body["error"] != "no_regulars" {
|
||||
t.Errorf("error = %v, want the Failure code", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobUnknownIDIsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobRequiresSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostJobThenGetProgressAndResult drives the whole flow over the
|
||||
// router: POST enqueues a progressive "draft" job, GET reads its partial
|
||||
// result while the handler is held mid-run, then its final result.
|
||||
func TestPostJobThenGetProgressAndResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, _ := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["id"].(string)
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
|
||||
t.Fatalf("body = %v, want only a job id", body)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner, _ := startedRunner(t, nil, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"},
|
||||
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"},
|
||||
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
|
||||
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
|
||||
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
const body = `{"kind":"noop","payload":{}}`
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
|
||||
}
|
||||
|
||||
for _, header := range []string{"", "wrong-token"} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
if header != "" {
|
||||
req.Header.Set(CSRFHeader, header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
||||
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -28,13 +29,17 @@ type RouteMounter interface {
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household; assets is the web build
|
||||
// /media; settings serves /api/v1/settings/household; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -63,6 +68,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -27,17 +28,17 @@ const (
|
||||
|
||||
func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWith(t, version, newTestSettings(t))
|
||||
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) http.Handler {
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,9 +71,8 @@ func TestAuthRoutesMounted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
// newTestStore returns a migrated temp database, closed when the test ends.
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
@@ -87,21 +87,49 @@ func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
return st
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return household.NewSettingsService(st)
|
||||
}
|
||||
|
||||
// newTestRunner returns an unstarted job runner over a migrated temp
|
||||
// database.
|
||||
func newTestRunner(t *testing.T) *jobs.Runner {
|
||||
t.Helper()
|
||||
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestGetHouseholdSettings(t *testing.T) {
|
||||
func TestPatchHouseholdSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
|
||||
var notified []household.Settings
|
||||
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
|
||||
@@ -146,7 +146,7 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
notified := 0
|
||||
settings.Subscribe(func(household.Settings) { notified++ })
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -262,3 +262,50 @@ func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
|
||||
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in
|
||||
// every 0001 status and checks 0003 carries each row over with its status
|
||||
// mapped and every other column unchanged.
|
||||
func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"}
|
||||
for old := range legacy {
|
||||
if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
|
||||
VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil {
|
||||
t.Fatalf("insert %s job: %v", old, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for old, want := range legacy {
|
||||
var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string
|
||||
if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old).
|
||||
Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read %s job: %v", old, err)
|
||||
}
|
||||
if status != want {
|
||||
t.Errorf("%s job status = %q, want %q", old, status, want)
|
||||
}
|
||||
got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt}
|
||||
if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) {
|
||||
t.Errorf("%s job columns = %v, want %v", old, got, wantCols)
|
||||
}
|
||||
}
|
||||
if tableExists(t, s, "jobs_new") {
|
||||
t.Error("jobs_new left behind")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func testRow(table string, i int) map[string]any {
|
||||
r["attempts"] = 0
|
||||
case "jobs":
|
||||
r["kind"] = "suggest"
|
||||
r["status"] = "pending"
|
||||
r["status"] = "queued"
|
||||
r["payload"] = "{}"
|
||||
case "schedule_runs":
|
||||
r["schedule"] = "plan_draft"
|
||||
@@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
||||
{"notification_prefs", "chat", 2},
|
||||
{"notification_outbox", "channel", "email"},
|
||||
{"notification_outbox", "status", "queued"},
|
||||
{"jobs", "status", "queued"},
|
||||
{"jobs", "status", "bogus"},
|
||||
{"schedule_runs", "schedule", "hourly"},
|
||||
}
|
||||
s := seededStore(t)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed,
|
||||
-- the states the job runner (internal/jobs) reports. SQLite cannot change a
|
||||
-- CHECK in place, so the table is rebuilt: create the new table, copy every
|
||||
-- row, drop the old one and rename. Nothing references jobs and it has no
|
||||
-- indexes or triggers, so the rebuild touches no other schema object. Rows
|
||||
-- written under the 0001 values are mapped (pending -> queued,
|
||||
-- done -> succeeded) so the copy cannot fail on an existing database.
|
||||
|
||||
CREATE TABLE jobs_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
||||
payload TEXT NOT NULL, -- JSON
|
||||
result TEXT, -- JSON, written progressively
|
||||
error TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
|
||||
SELECT
|
||||
id,
|
||||
kind,
|
||||
CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END,
|
||||
payload, result, error, started_at, finished_at, created_at, updated_at
|
||||
FROM jobs;
|
||||
|
||||
DROP TABLE jobs;
|
||||
|
||||
ALTER TABLE jobs_new RENAME TO jobs;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- name: CreateJob :exec
|
||||
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?);
|
||||
|
||||
-- name: ClaimNextJob :one
|
||||
-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
-- when no job is queued.
|
||||
UPDATE jobs
|
||||
SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||
RETURNING *;
|
||||
|
||||
-- name: PublishJobResult :execrows
|
||||
-- Stores a partial result of a running job.
|
||||
UPDATE jobs
|
||||
SET result = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running';
|
||||
|
||||
-- name: SucceedJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailRunningJobs :execrows
|
||||
-- Fails every job left running, at boot before any worker starts.
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE status = 'running';
|
||||
|
||||
-- name: GetJob :one
|
||||
SELECT * FROM jobs
|
||||
WHERE id = ?;
|
||||
+4
-2
@@ -5,10 +5,12 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit && vite build && npm run check:dist-fonts",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "npm run lint:colors && vitest run",
|
||||
"lint:colors": "node scripts/lint-colors.mjs",
|
||||
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// 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')
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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')
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
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.
@@ -0,0 +1,93 @@
|
||||
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,8 @@
|
||||
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'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* App-wide base styles. Values come only from tokens.css. */
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
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} />
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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!)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Card.module.css'
|
||||
|
||||
export type CardTone = 'surface' | 'muted'
|
||||
|
||||
export type CardProps = ComponentPropsWithRef<'div'> & {
|
||||
// surface = --color-surface; muted = the secondary neutral-100 fill.
|
||||
tone?: CardTone
|
||||
}
|
||||
|
||||
export function Card({ tone = 'surface', className, ...rest }: CardProps) {
|
||||
const classes = [styles.card, styles[tone], className].filter(Boolean).join(' ')
|
||||
return <div className={classes} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The check mark. */
|
||||
.input::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--on-accent);
|
||||
clip-path: polygon(14% 44%, 0 59%, 40% 100%, 100% 21%, 85% 7%, 39% 69%);
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: transform 160ms var(--ease-spring), opacity 160ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.input:checked {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.input:checked::before {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.input:disabled + .label {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.input::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Checkbox } from './Checkbox.tsx'
|
||||
|
||||
describe('Checkbox', () => {
|
||||
it('is a native checkbox named by its label', () => {
|
||||
render(<Checkbox label="Chicken thighs" />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Chicken thighs' })
|
||||
expect(box.tagName).toBe('INPUT')
|
||||
expect(box).toHaveAttribute('type', 'checkbox')
|
||||
expect(box).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('toggles when the input or its label text is clicked', () => {
|
||||
render(<Checkbox label="Limes" />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Limes' })
|
||||
fireEvent.click(box)
|
||||
expect(box).toBeChecked()
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(box).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('reflects a controlled checked state and disabled', () => {
|
||||
render(<Checkbox label="Rice" checked disabled readOnly />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Rice' })
|
||||
expect(box).toBeChecked()
|
||||
expect(box).toBeDisabled()
|
||||
})
|
||||
|
||||
it('has a ≥ 44 px hit area (the wrapping label)', () => {
|
||||
render(<Checkbox label="Rice" />)
|
||||
const hit = screen.getByRole('checkbox', { name: 'Rice' }).closest('label')!
|
||||
expectMinHitArea(hit)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
||||
import styles from './Checkbox.module.css'
|
||||
|
||||
export type CheckboxProps = Omit<ComponentPropsWithRef<'input'>, 'type'> & {
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
// Native checkbox, styled. The wrapping label is the hit area (at least
|
||||
// 44×44), so tapping the text toggles it too.
|
||||
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
|
||||
return (
|
||||
<label className={[styles.root, className].filter(Boolean).join(' ')}>
|
||||
<input type="checkbox" className={styles.input} {...rest} />
|
||||
<span className={styles.label}>{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removable {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 44 px hit box around a small cross; negative margins keep the chip 36 px
|
||||
tall and the cross close to the label. */
|
||||
.remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -4px 0 -4px -14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Chip } from './Chip.tsx'
|
||||
|
||||
describe('Chip', () => {
|
||||
it('has no remove button unless onRemove is given', () => {
|
||||
render(<Chip label="Beef tacos" />)
|
||||
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes an accessible "Remove {label}" button that calls onRemove', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<Chip label="Beef tacos" onRemove={onRemove} />)
|
||||
const remove = screen.getByRole('button', { name: 'Remove Beef tacos' })
|
||||
expect(remove).toHaveAttribute('type', 'button')
|
||||
fireEvent.click(remove)
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('gives the remove button a ≥ 44 px hit area', () => {
|
||||
render(<Chip label="Pizza night" onRemove={() => {}} />)
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Chip.module.css'
|
||||
|
||||
export type ChipProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
|
||||
label: string
|
||||
// When set, the chip shows a "Remove {label}" button that calls it.
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
export function Chip({ label, onRemove, className, ...rest }: ChipProps) {
|
||||
const classes = [styles.chip, onRemove && styles.removable, className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<span className={classes} {...rest}>
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button type="button" className={styles.remove} aria-label={`Remove ${label}`} onClick={onRemove}>
|
||||
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
|
||||
<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
max-width: 32ch;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { EmptyState } from './EmptyState.tsx'
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('shows a Bagel title and body without an action by default', () => {
|
||||
render(<EmptyState title="Quest log is empty">Swipe Try it on a Side quest to save it here.</EmptyState>)
|
||||
const title = screen.getByRole('heading', { name: 'Quest log is empty' })
|
||||
expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)')
|
||||
expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the optional primary action and calls its handler', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<EmptyState title="No Regulars yet" action={{ label: 'Add a dinner', onClick }}>
|
||||
Add the dinners you make most.
|
||||
</EmptyState>,
|
||||
)
|
||||
const action = screen.getByRole('button', { name: 'Add a dinner' })
|
||||
expectMinHitArea(action)
|
||||
fireEvent.click(action)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button.tsx'
|
||||
import styles from './EmptyState.module.css'
|
||||
|
||||
export type EmptyStateAction = {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export type EmptyStateProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
// Optional primary action, rendered as a primary Button.
|
||||
action?: EmptyStateAction
|
||||
}
|
||||
|
||||
// Bagel title, body text and an optional primary action.
|
||||
export function EmptyState({ title, children, action }: EmptyStateProps) {
|
||||
return (
|
||||
<section className={styles.empty}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.body}>{children}</p>
|
||||
{action && (
|
||||
<Button className={styles.action} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--color-accent-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { ErrorBanner } from './ErrorBanner.tsx'
|
||||
|
||||
describe('ErrorBanner', () => {
|
||||
it('announces the message and calls onRetry from the Retry button', () => {
|
||||
const onRetry = vi.fn()
|
||||
render(<ErrorBanner onRetry={onRetry}>Couldn't load the Supply run.</ErrorBanner>)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.")
|
||||
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||
expectMinHitArea(retry)
|
||||
fireEvent.click(retry)
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses a caller-supplied retry label', () => {
|
||||
render(
|
||||
<ErrorBanner onRetry={() => {}} retryLabel="Rebuild week">
|
||||
The plan didn't save.
|
||||
</ErrorBanner>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a neutral-100 card with grape-700 text', () => {
|
||||
render(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
|
||||
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
|
||||
expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button.tsx'
|
||||
import { Card } from './Card.tsx'
|
||||
import styles from './ErrorBanner.module.css'
|
||||
|
||||
export type ErrorBannerProps = {
|
||||
// What went wrong, in the caller's words.
|
||||
children: ReactNode
|
||||
onRetry: () => void
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
// Muted neutral-100 card with grape-700 text and a Retry button.
|
||||
export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
|
||||
return (
|
||||
<Card tone="muted" role="alert" className={styles.banner}>
|
||||
<p className={styles.message}>{children}</p>
|
||||
<Button onClick={onRetry}>{retryLabel}</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* The button is the hit box; .face is the visible circle. A 40 px face sits in
|
||||
a 44 px hit box whose negative margin keeps the 40 px layout footprint. */
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.size40 {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.size62 {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.face {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-pill);
|
||||
transition: transform 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.size40 .face {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.size62 .face {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) .face {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.teal {
|
||||
background: var(--color-accent-2-600);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.face {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { IconButton } from './IconButton.tsx'
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('is named by its label, not its icon', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<IconButton label="Notifications" onClick={onClick}>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Notifications' })
|
||||
expect(button).toHaveAttribute('type', 'button')
|
||||
fireEvent.click(button)
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a 44 px hit area around the 40 px circle', () => {
|
||||
render(<IconButton label="Back" size={40} />)
|
||||
const button = screen.getByRole('button', { name: 'Back' })
|
||||
expectMinHitArea(button)
|
||||
const face = button.firstElementChild!
|
||||
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
||||
})
|
||||
|
||||
it('is 62 px at the large size', () => {
|
||||
render(<IconButton label="Try it" size={62} tone="teal" />)
|
||||
const button = screen.getByRole('button', { name: 'Try it' })
|
||||
expectMinHitArea(button)
|
||||
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './IconButton.module.css'
|
||||
|
||||
export type IconButtonSize = 40 | 62
|
||||
export type IconButtonTone = 'surface' | 'accent' | 'teal'
|
||||
|
||||
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||
// Accessible name; an icon alone never names a button.
|
||||
label: string
|
||||
size?: IconButtonSize
|
||||
tone?: IconButtonTone
|
||||
}
|
||||
|
||||
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
|
||||
// the 62 px size is the round action button with --shadow-md.
|
||||
export function IconButton({
|
||||
label,
|
||||
size = 40,
|
||||
tone = 'surface',
|
||||
type = 'button',
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: IconButtonProps) {
|
||||
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<button type={type} aria-label={label} className={classes} {...rest}>
|
||||
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 14px 16px;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.interactive {
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.main {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 1 1 auto;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.interactive > .trailing {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.leading,
|
||||
.trailing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { ListRow } from './ListRow.tsx'
|
||||
import { Toggle } from './Toggle.tsx'
|
||||
|
||||
describe('ListRow', () => {
|
||||
it('renders leading, title, meta and trailing slots without being interactive', () => {
|
||||
render(
|
||||
<ListRow
|
||||
leading={<span>icon</span>}
|
||||
title="Notifications"
|
||||
meta="Push and Google Chat"
|
||||
trailing={<span>chevron</span>}
|
||||
/>,
|
||||
)
|
||||
for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) {
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a single button with a ≥ 44 px hit area when it has onClick', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ListRow title="10 new side quests to swipe" onClick={onClick} />)
|
||||
const row = screen.getByRole('button', { name: '10 new side quests to swipe' })
|
||||
expect(row).toHaveAttribute('type', 'button')
|
||||
expectMinHitArea(row)
|
||||
fireEvent.click(row)
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a trailing toggle outside the row button so each control fires only its own action', () => {
|
||||
const onClick = vi.fn()
|
||||
const onCheckedChange = vi.fn()
|
||||
render(
|
||||
<ListRow
|
||||
title="Dinner reminders"
|
||||
meta="Push at 4 pm"
|
||||
trailing={<Toggle aria-label="Dinner reminders" checked={false} onCheckedChange={onCheckedChange} />}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('button', { name: /^Dinner reminders/ })
|
||||
const toggle = screen.getByRole('switch', { name: 'Dinner reminders' })
|
||||
expect(row).not.toContainElement(toggle)
|
||||
expect(row.querySelector('button')).toBeNull()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(row)
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
expect(onCheckedChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './ListRow.module.css'
|
||||
|
||||
export type ListRowProps = {
|
||||
title: ReactNode
|
||||
meta?: ReactNode
|
||||
// Leading slot: icon, photo or avatar.
|
||||
leading?: ReactNode
|
||||
// Trailing slot: chevron, toggle, count or action.
|
||||
trailing?: ReactNode
|
||||
// When set, the leading slot, title and meta form one button. The trailing
|
||||
// slot stays a sibling outside it, so a trailing toggle or action is never
|
||||
// nested in the row's button and never triggers the row's action.
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) {
|
||||
const main = (
|
||||
<>
|
||||
{leading && <span className={styles.leading}>{leading}</span>}
|
||||
<span className={styles.body}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
{meta && <span className={styles.meta}>{meta}</span>}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const trailingSlot = trailing && <span className={styles.trailing}>{trailing}</span>
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className={[styles.row, styles.interactive, className].filter(Boolean).join(' ')}>
|
||||
<button type="button" className={styles.main} onClick={onClick}>
|
||||
{main}
|
||||
</button>
|
||||
{trailingSlot}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={[styles.row, className].filter(Boolean).join(' ')}>
|
||||
{main}
|
||||
{trailingSlot}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.track {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 1 1 0;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-neutral-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.option {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Segmented } from './Segmented.tsx'
|
||||
import styles from './Segmented.module.css'
|
||||
|
||||
const options = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
] as const
|
||||
|
||||
type Period = (typeof options)[number]['value']
|
||||
|
||||
function Harness({ initial = 'week' }: { initial?: Period }) {
|
||||
const [value, setValue] = useState<Period>(initial)
|
||||
return <Segmented label="Archive period" options={options} value={value} onValueChange={setValue} />
|
||||
}
|
||||
|
||||
function radio(name: string) {
|
||||
return screen.getByRole('radio', { name })
|
||||
}
|
||||
|
||||
describe('Segmented', () => {
|
||||
it('is a named radiogroup of radios with one checked', () => {
|
||||
render(<Harness />)
|
||||
expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Month')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(radio('Week')).toHaveClass(styles.selected!)
|
||||
})
|
||||
|
||||
it('has a single tab stop on the selected option', () => {
|
||||
render(<Harness initial="month" />)
|
||||
expect(radio('Month')).toHaveAttribute('tabindex', '0')
|
||||
expect(radio('Week')).toHaveAttribute('tabindex', '-1')
|
||||
expect(radio('Year')).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
|
||||
it('moves selection and focus with the arrow keys, wrapping at the ends', () => {
|
||||
render(<Harness />)
|
||||
radio('Week').focus()
|
||||
|
||||
fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' })
|
||||
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Month')).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' })
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' })
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Week')).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' })
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' })
|
||||
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Month')).toHaveFocus()
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('jumps to the first and last option with Home and End', () => {
|
||||
render(<Harness initial="month" />)
|
||||
fireEvent.keyDown(radio('Month'), { key: 'End' })
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.keyDown(radio('Year'), { key: 'Home' })
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('selects an option on click', () => {
|
||||
render(<Harness />)
|
||||
fireEvent.click(radio('Year'))
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('gives every option a ≥ 44 px hit area', () => {
|
||||
render(<Harness />)
|
||||
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
||||
})
|
||||
|
||||
it('fails fast when the value is not one of the options', () => {
|
||||
expect(() =>
|
||||
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||
).toThrow('value "decade" is not one of its options')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useRef, type KeyboardEvent } from 'react'
|
||||
import styles from './Segmented.module.css'
|
||||
|
||||
export type SegmentedOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SegmentedProps<T extends string> = {
|
||||
// Accessible name of the radiogroup.
|
||||
label: string
|
||||
options: readonly SegmentedOption<T>[]
|
||||
value: T
|
||||
onValueChange: (value: T) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const step: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }
|
||||
|
||||
// Segmented control with radiogroup semantics: one tab stop (the selected
|
||||
// option); arrow keys move the selection and focus, wrapping at the ends;
|
||||
// Home and End jump to the first and last option.
|
||||
export function Segmented<T extends string>({ label, options, value, onValueChange, className }: SegmentedProps<T>) {
|
||||
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const selected = options.findIndex((option) => option.value === value)
|
||||
if (selected === -1) {
|
||||
throw new Error(`Segmented "${label}": value "${value}" is not one of its options`)
|
||||
}
|
||||
|
||||
function select(index: number) {
|
||||
const option = options[index]
|
||||
if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`)
|
||||
onValueChange(option.value)
|
||||
refs.current[index]?.focus()
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const delta = step[event.key]
|
||||
let next: number
|
||||
if (delta !== undefined) next = (selected + delta + options.length) % options.length
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = options.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
select(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={[styles.track, className].filter(Boolean).join(' ')}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const checked = index === selected
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
refs.current[index] = el
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||
onClick={() => select(index)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
|
||||
animation: fade 320ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
max-height: 90dvh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(0);
|
||||
animation: rise 320ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.panel:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scrim,
|
||||
.panel {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import '../styles/tokens.css'
|
||||
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
|
||||
import { Sheet } from './Sheet.tsx'
|
||||
import styles from './Sheet.module.css'
|
||||
|
||||
function Harness({ withControls = true }: { withControls?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Open Supply run
|
||||
</button>
|
||||
<Sheet aria-label="Supply run" open={open} onClose={() => setOpen(false)}>
|
||||
<p>Grouped by dinner</p>
|
||||
{withControls && (
|
||||
<>
|
||||
<button type="button">Try it</button>
|
||||
<button type="button">Pass</button>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function openSheet() {
|
||||
const opener = screen.getByRole('button', { name: 'Open Supply run' })
|
||||
opener.focus()
|
||||
fireEvent.click(opener)
|
||||
return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) }
|
||||
}
|
||||
|
||||
function scrim() {
|
||||
const element = document.querySelector(`.${styles.scrim}`)
|
||||
if (!element) throw new Error('scrim not rendered')
|
||||
return element
|
||||
}
|
||||
|
||||
describe('Sheet', () => {
|
||||
it('renders nothing while closed', () => {
|
||||
render(<Harness />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a labelled modal dialog', () => {
|
||||
render(<Harness />)
|
||||
const { dialog } = openSheet()
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it('accepts aria-labelledby as its accessible name', () => {
|
||||
render(
|
||||
<>
|
||||
<h2 id="sheet-title">Quest log</h2>
|
||||
<Sheet aria-labelledby="sheet-title" open onClose={() => {}}>
|
||||
<p>Nothing yet</p>
|
||||
</Sheet>
|
||||
</>,
|
||||
)
|
||||
expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves focus to the first control and restores it to the opener on close', () => {
|
||||
render(<Harness />)
|
||||
const { opener } = openSheet()
|
||||
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(opener).toHaveFocus()
|
||||
})
|
||||
|
||||
it('focuses the dialog itself when it has no focusable control', () => {
|
||||
render(<Harness withControls={false} />)
|
||||
const { dialog } = openSheet()
|
||||
expect(dialog).toHaveFocus()
|
||||
})
|
||||
|
||||
it('skips controls that cannot take keyboard focus when moving focus in', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<button type="button" hidden>
|
||||
Hidden
|
||||
</button>
|
||||
<button type="button" style={{ display: 'none' }}>
|
||||
Not displayed
|
||||
</button>
|
||||
<button type="button" style={{ visibility: 'hidden' }}>
|
||||
Invisible
|
||||
</button>
|
||||
<button type="button" tabIndex={-1}>
|
||||
Not tabbable
|
||||
</button>
|
||||
<div aria-hidden="true">
|
||||
<button type="button">Aria hidden</button>
|
||||
</div>
|
||||
<button type="button">Try it</button>
|
||||
</Sheet>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('focuses the dialog itself when every control is hidden', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<button type="button" hidden>
|
||||
Hidden
|
||||
</button>
|
||||
</Sheet>,
|
||||
)
|
||||
expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not stop the Tab trap on a hidden control', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<button type="button" hidden>
|
||||
Hidden first
|
||||
</button>
|
||||
<button type="button">Try it</button>
|
||||
<button type="button">Pass</button>
|
||||
<button type="button" hidden>
|
||||
Hidden last
|
||||
</button>
|
||||
</Sheet>,
|
||||
)
|
||||
const first = screen.getByRole('button', { name: 'Try it' })
|
||||
const last = screen.getByRole('button', { name: 'Pass' })
|
||||
|
||||
last.focus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not stop the Tab trap on a control disabled by its fieldset', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<fieldset disabled>
|
||||
<button type="button">Disabled first</button>
|
||||
</fieldset>
|
||||
<button type="button">Try it</button>
|
||||
<button type="button">Pass</button>
|
||||
<fieldset disabled>
|
||||
<button type="button">Disabled last</button>
|
||||
</fieldset>
|
||||
</Sheet>,
|
||||
)
|
||||
const first = screen.getByRole('button', { name: 'Try it' })
|
||||
const last = screen.getByRole('button', { name: 'Pass' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
last.focus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
|
||||
it('wraps Tab and Shift+Tab inside the sheet', () => {
|
||||
render(<Harness />)
|
||||
openSheet()
|
||||
const first = screen.getByRole('button', { name: 'Try it' })
|
||||
const last = screen.getByRole('button', { name: 'Pass' })
|
||||
|
||||
last.focus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps focus on the dialog when Tab has nowhere to go', () => {
|
||||
render(<Harness withControls={false} />)
|
||||
const { dialog } = openSheet()
|
||||
const tab = fireEvent.keyDown(dialog, { key: 'Tab' })
|
||||
expect(tab).toBe(false) // default prevented
|
||||
expect(dialog).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Sheet aria-label="Supply run" open onClose={onClose}>
|
||||
<button type="button">Try it</button>
|
||||
</Sheet>,
|
||||
)
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closes on a scrim click but not on a panel click', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Sheet aria-label="Supply run" open onClose={onClose}>
|
||||
<p>Grouped by dinner</p>
|
||||
</Sheet>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('dialog'))
|
||||
fireEvent.click(screen.getByText('Grouped by dinner'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.click(scrim())
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('makes the background inert while open and restores it on close', () => {
|
||||
const { container } = render(<Harness />)
|
||||
openSheet()
|
||||
const root = screen.getByRole('dialog').parentElement!
|
||||
expect(root.parentElement).toBe(document.body)
|
||||
expect(container).toHaveAttribute('inert')
|
||||
expect(root).not.toHaveAttribute('inert')
|
||||
|
||||
fireEvent.click(scrim())
|
||||
expect(container).not.toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('leaves an already-inert background element inert after close', () => {
|
||||
const other = document.createElement('div')
|
||||
other.setAttribute('inert', '')
|
||||
document.body.append(other)
|
||||
try {
|
||||
render(<Harness />)
|
||||
openSheet()
|
||||
fireEvent.click(scrim())
|
||||
expect(other).toHaveAttribute('inert')
|
||||
} finally {
|
||||
other.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('rises from 100% to 0 over 320 ms with the soft ease-out', () => {
|
||||
render(<Harness />)
|
||||
const { dialog } = openSheet()
|
||||
expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
|
||||
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft')
|
||||
expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)')
|
||||
const rise = animationKeyframes(dialog)
|
||||
expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)')
|
||||
expect(rise.findRule('to')?.style.transform).toBe('translateY(0)')
|
||||
})
|
||||
|
||||
it('fades the scrim in to 50% neutral-900', () => {
|
||||
render(<Harness />)
|
||||
openSheet()
|
||||
const style = declaredStyle(scrim())
|
||||
expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)')
|
||||
expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
|
||||
const fade = animationKeyframes(scrim())
|
||||
expect(fade.findRule('from')?.style.opacity).toBe('0')
|
||||
expect(fade.findRule('to')?.style.opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('opens in place with no rise or fade under reduced motion', () => {
|
||||
render(<Harness />)
|
||||
const { dialog } = openSheet()
|
||||
expect(reducedMotionStyle(dialog).animation).toBe('none')
|
||||
expect(reducedMotionStyle(scrim()).animation).toBe('none')
|
||||
expect(declaredStyle(dialog).transform).toBe('translateY(0)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './Sheet.module.css'
|
||||
|
||||
// The dialog's accessible name: exactly one of a label or a labelling element.
|
||||
type SheetName =
|
||||
| { 'aria-label': string; 'aria-labelledby'?: never }
|
||||
| { 'aria-labelledby': string; 'aria-label'?: never }
|
||||
|
||||
export type SheetProps = SheetName & {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const focusableSelector = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'[tabindex]',
|
||||
].join(',')
|
||||
|
||||
// Whether an element can take keyboard focus: being disabled (by its own
|
||||
// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden
|
||||
// or inert ancestor (or self), display: none on the way up to the panel, or
|
||||
// visibility: hidden all rule it out.
|
||||
function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean {
|
||||
if (element.matches(':disabled')) return false
|
||||
if (element.tabIndex < 0) return false
|
||||
if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false
|
||||
if (getComputedStyle(element).visibility === 'hidden') return false
|
||||
for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) {
|
||||
if (getComputedStyle(node).display === 'none') return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function focusables(panel: HTMLElement): HTMLElement[] {
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(focusableSelector)).filter((element) =>
|
||||
isKeyboardFocusable(element, panel),
|
||||
)
|
||||
}
|
||||
|
||||
// Focuses the first control that actually takes focus, or the panel if none does.
|
||||
function focusFirst(panel: HTMLElement) {
|
||||
for (const element of focusables(panel)) {
|
||||
element.focus()
|
||||
if (document.activeElement === element) return
|
||||
}
|
||||
panel.focus()
|
||||
}
|
||||
|
||||
// Modal bottom sheet. Portalled to <body> so everything else there can be made
|
||||
// inert while it is open. Focus moves in on open, Tab is trapped inside, and
|
||||
// focus returns to the opener on close. Escape and a scrim click call onClose.
|
||||
export function Sheet({ open, onClose, children, ...name }: SheetProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const panel = panelRef.current
|
||||
if (!open || !root || !panel) return
|
||||
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const background = Array.from(document.body.children).filter(
|
||||
(element) => element !== root && !element.hasAttribute('inert'),
|
||||
)
|
||||
for (const element of background) element.setAttribute('inert', '')
|
||||
focusFirst(panel)
|
||||
|
||||
return () => {
|
||||
for (const element of background) element.removeAttribute('inert')
|
||||
opener?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab' || !panelRef.current) return
|
||||
const items = focusables(panelRef.current)
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
if (!first || !last) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const active = document.activeElement
|
||||
if (event.shiftKey && (active === first || active === panelRef.current)) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div ref={rootRef} className={styles.root}>
|
||||
<div className={styles.scrim} aria-hidden="true" onClick={onClose} />
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
className={styles.panel}
|
||||
onKeyDown={onKeyDown}
|
||||
{...name}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.skeleton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
|
||||
import { Skeleton } from './Skeleton.tsx'
|
||||
|
||||
function skeleton(container: HTMLElement) {
|
||||
return container.firstElementChild as HTMLElement
|
||||
}
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('is a hidden pill in --color-surface', () => {
|
||||
const { container } = render(<Skeleton />)
|
||||
const element = skeleton(container)
|
||||
expect(element).toHaveAttribute('aria-hidden', 'true')
|
||||
const style = declaredStyle(element)
|
||||
expect(style.borderRadius).toBe('var(--radius-pill)')
|
||||
expect(style.background).toBe('var(--color-surface)')
|
||||
})
|
||||
|
||||
it('pulses its opacity over 1.2 s', () => {
|
||||
const { container } = render(<Skeleton />)
|
||||
expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite'])
|
||||
expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45')
|
||||
})
|
||||
|
||||
it('does not pulse under reduced motion', () => {
|
||||
const { container } = render(<Skeleton />)
|
||||
expect(reducedMotionStyle(skeleton(container)).animation).toBe('none')
|
||||
})
|
||||
|
||||
it('merges a caller className and forwards style', () => {
|
||||
const { container } = render(<Skeleton className="line" style={{ width: '60%' }} />)
|
||||
expect(skeleton(container)).toHaveClass('line')
|
||||
expect(skeleton(container).style.width).toBe('60%')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Skeleton.module.css'
|
||||
|
||||
export type SkeletonProps = Omit<ComponentPropsWithRef<'div'>, 'children'>
|
||||
|
||||
// Loading placeholder: a pill in --color-surface with a 1.2 s opacity pulse.
|
||||
// Full width and 16 px tall by default; size it with className or style.
|
||||
// Hidden from assistive technology — announce loading on the container.
|
||||
export function Skeleton({ className, ...rest }: SkeletonProps) {
|
||||
const classes = [styles.skeleton, className].filter(Boolean).join(' ')
|
||||
return <div aria-hidden="true" className={classes} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* The input is the 44 px tall hit area; the track and knob are drawn by the
|
||||
vendor pseudo-elements. --slider-fill is set inline from the value. */
|
||||
.slider {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-runnable-track {
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-accent) var(--slider-fill),
|
||||
var(--color-neutral-300) var(--slider-fill)
|
||||
);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: -8px;
|
||||
border: 3px solid var(--color-accent);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.slider::-moz-range-progress {
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--color-accent);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Slider } from './Slider.tsx'
|
||||
|
||||
function Harness() {
|
||||
const [value, setValue] = useState(0.7)
|
||||
return (
|
||||
<Slider
|
||||
aria-label="Creativity (temperature)"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={value}
|
||||
onChange={(event) => setValue(Number(event.target.value))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Slider', () => {
|
||||
it('is a native slider exposing its range and value', () => {
|
||||
render(<Harness />)
|
||||
const slider = screen.getByRole('slider', { name: 'Creativity (temperature)' })
|
||||
expect(slider).toHaveAttribute('type', 'range')
|
||||
expect(slider).toHaveAttribute('min', '0')
|
||||
expect(slider).toHaveAttribute('max', '1')
|
||||
expect(slider).toHaveValue('0.7')
|
||||
})
|
||||
|
||||
it('updates the grape fill with the value', () => {
|
||||
render(<Harness />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider.style.getPropertyValue('--slider-fill')).toBe('70%')
|
||||
fireEvent.change(slider, { target: { value: '0.2' } })
|
||||
expect(slider).toHaveValue('0.2')
|
||||
expect(slider.style.getPropertyValue('--slider-fill')).toBe('20%')
|
||||
})
|
||||
|
||||
it('has a ≥ 44 px hit area', () => {
|
||||
render(<Harness />)
|
||||
expectMinHitArea(screen.getByRole('slider'))
|
||||
})
|
||||
|
||||
it('fails fast on an empty range', () => {
|
||||
expect(() => render(<Slider aria-label="Bad" min={1} max={1} value={1} readOnly />)).toThrow(
|
||||
'max (1) must be greater than min (1)',
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts values at both ends of the range', () => {
|
||||
render(
|
||||
<>
|
||||
<Slider aria-label="At min" min={0} max={1} value={0} readOnly />
|
||||
<Slider aria-label="At max" min={0} max={1} value={1} readOnly />
|
||||
</>,
|
||||
)
|
||||
expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%')
|
||||
expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[2, 'value (2) must be within [0, 1]'],
|
||||
[-0.1, 'value (-0.1) must be within [0, 1]'],
|
||||
[Number.NaN, 'value (NaN) must be within [0, 1]'],
|
||||
])('fails fast when the value %s is outside the range', (value, message) => {
|
||||
expect(() => render(<Slider aria-label="Bad" min={0} max={1} value={value} readOnly />)).toThrow(message)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CSSProperties, ComponentPropsWithRef } from 'react'
|
||||
import styles from './Slider.module.css'
|
||||
|
||||
export type SliderProps = Omit<ComponentPropsWithRef<'input'>, 'type' | 'value' | 'min' | 'max'> & {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
// Native range input, styled: 8 px track, grape fill up to the value, 24 px
|
||||
// knob with a 3 px grape ring. Controlled, because the fill follows `value`.
|
||||
// Name it with aria-label or aria-labelledby.
|
||||
export function Slider({ value, min, max, className, style, ...rest }: SliderProps) {
|
||||
if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`)
|
||||
if (!(value >= min && value <= max)) {
|
||||
throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`)
|
||||
}
|
||||
const fill = `${((value - min) / (max - min)) * 100}%`
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={[styles.slider, className].filter(Boolean).join(' ')}
|
||||
style={{ ...style, '--slider-fill': fill } as CSSProperties}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grape {
|
||||
background: var(--color-accent-100);
|
||||
color: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.teal {
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Tag } from './Tag.tsx'
|
||||
import styles from './Tag.module.css'
|
||||
|
||||
describe('Tag', () => {
|
||||
it('renders a static, non-interactive label', () => {
|
||||
render(<Tag>Draft</Tag>)
|
||||
const tag = screen.getByText('Draft')
|
||||
expect(tag).toHaveClass(styles.grape!)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports the teal tone', () => {
|
||||
render(<Tag tone="teal">Synced</Tag>)
|
||||
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Tag.module.css'
|
||||
|
||||
export type TagTone = 'grape' | 'teal'
|
||||
|
||||
export type TagProps = ComponentPropsWithRef<'span'> & {
|
||||
tone?: TagTone
|
||||
}
|
||||
|
||||
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
|
||||
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
|
||||
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
|
||||
return <span className={classes} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.hiddenLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
min-width: 44px;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { TextInput } from './TextInput.tsx'
|
||||
|
||||
describe('TextInput', () => {
|
||||
it('is a textbox named by its label', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TextInput label="Dinner name" placeholder="e.g. Chicken enchiladas" onChange={onChange} />)
|
||||
const input = screen.getByRole('textbox', { name: 'Dinner name' })
|
||||
fireEvent.change(input, { target: { value: 'Chili' } })
|
||||
expect(onChange).toHaveBeenCalledOnce()
|
||||
expect(input).toHaveValue('Chili')
|
||||
})
|
||||
|
||||
it('keeps the accessible name when the label is visually hidden', () => {
|
||||
render(<TextInput label="Add a regular" labelHidden />)
|
||||
expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a 48 px pill with a ≥ 44 px hit area', () => {
|
||||
render(<TextInput label="Dinner name" />)
|
||||
const input = screen.getByRole('textbox', { name: 'Dinner name' })
|
||||
expectMinHitArea(input)
|
||||
expect(hitArea(input).height).toBe(48)
|
||||
})
|
||||
|
||||
it('forwards the ref', () => {
|
||||
const ref = createRef<HTMLInputElement>()
|
||||
render(<TextInput label="Dinner name" ref={ref} />)
|
||||
expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useId, type ComponentPropsWithRef } from 'react'
|
||||
import styles from './TextInput.module.css'
|
||||
|
||||
export type TextInputProps = Omit<ComponentPropsWithRef<'input'>, 'id'> & {
|
||||
label: string
|
||||
// Hide the label visually; it stays the input's accessible name.
|
||||
labelHidden?: boolean
|
||||
}
|
||||
|
||||
// 48 px pill input on the surface fill, always associated with its label.
|
||||
export function TextInput({ label, labelHidden = false, type = 'text', className, ...rest }: TextInputProps) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label htmlFor={id} className={labelHidden ? styles.hiddenLabel : styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
<input id={id} type={type} className={[styles.input, className].filter(Boolean).join(' ')} {...rest} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* The button is a 44 px hit box around the 40×24 track; negative margins keep
|
||||
the 40×24 layout footprint. */
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -10px -2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-300);
|
||||
transition: background-color 150ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 180ms var(--ease-spring);
|
||||
}
|
||||
|
||||
.toggle[aria-checked='true'] .track {
|
||||
background: var(--color-accent-2-600);
|
||||
}
|
||||
|
||||
.toggle[aria-checked='true'] .knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.track,
|
||||
.knob {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Toggle } from './Toggle.tsx'
|
||||
|
||||
function Harness() {
|
||||
const [checked, setChecked] = useState(false)
|
||||
return <Toggle aria-label="Push: New weekly plan ready" checked={checked} onCheckedChange={setChecked} />
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
return screen.getByRole('switch', { name: 'Push: New weekly plan ready' })
|
||||
}
|
||||
|
||||
describe('Toggle', () => {
|
||||
it('is a switch reflecting its state in aria-checked', () => {
|
||||
render(<Harness />)
|
||||
expect(toggle()).toHaveAttribute('aria-checked', 'false')
|
||||
fireEvent.click(toggle())
|
||||
expect(toggle()).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.click(toggle())
|
||||
expect(toggle()).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it.each([' ', 'Enter'])('toggles with the %j key', (key) => {
|
||||
render(<Harness />)
|
||||
fireEvent.keyDown(toggle(), { key })
|
||||
expect(toggle()).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.keyDown(toggle(), { key })
|
||||
expect(toggle()).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('suppresses native activation for Space and Enter so a key press toggles once', () => {
|
||||
render(<Harness />)
|
||||
expect(fireEvent.keyDown(toggle(), { key: ' ' })).toBe(false)
|
||||
expect(fireEvent.keyUp(toggle(), { key: ' ' })).toBe(false)
|
||||
expect(fireEvent.keyDown(toggle(), { key: 'Enter' })).toBe(false)
|
||||
expect(fireEvent.keyDown(toggle(), { key: 'a' })).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores other keys', () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
render(<Toggle aria-label="Chat" checked={false} onCheckedChange={onCheckedChange} />)
|
||||
fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' })
|
||||
expect(onCheckedChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is disabled when asked', () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
render(<Toggle aria-label="Chat" checked={false} disabled onCheckedChange={onCheckedChange} />)
|
||||
const sw = screen.getByRole('switch', { name: 'Chat' })
|
||||
expect(sw).toBeDisabled()
|
||||
fireEvent.click(sw)
|
||||
expect(onCheckedChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a 40×24 track inside a ≥ 44 px hit area', () => {
|
||||
render(<Harness />)
|
||||
expectMinHitArea(toggle())
|
||||
expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ComponentPropsWithRef, KeyboardEvent } from 'react'
|
||||
import styles from './Toggle.module.css'
|
||||
|
||||
export type ToggleProps = Omit<
|
||||
ComponentPropsWithRef<'button'>,
|
||||
'type' | 'role' | 'aria-checked' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'children'
|
||||
> & {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
// 40×24 pill switch (role="switch"). Name it with aria-label or
|
||||
// aria-labelledby. Space and Enter are handled here and their native button
|
||||
// activation is suppressed, so each key press toggles exactly once.
|
||||
export function Toggle({ checked, onCheckedChange, className, ...rest }: ToggleProps) {
|
||||
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (event.key !== ' ' && event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
onCheckedChange(!checked)
|
||||
}
|
||||
|
||||
function onKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (event.key === ' ') event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
className={[styles.toggle, className].filter(Boolean).join(' ')}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
{...rest}
|
||||
>
|
||||
<span className={styles.track} aria-hidden="true">
|
||||
<span className={styles.knob} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx'
|
||||
export { Card, type CardProps, type CardTone } from './Card.tsx'
|
||||
export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
|
||||
export { Chip, type ChipProps } from './Chip.tsx'
|
||||
export { EmptyState, type EmptyStateAction, type EmptyStateProps } from './EmptyState.tsx'
|
||||
export { ErrorBanner, type ErrorBannerProps } from './ErrorBanner.tsx'
|
||||
export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
|
||||
export { ListRow, type ListRowProps } from './ListRow.tsx'
|
||||
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
|
||||
export { Sheet, type SheetProps } from './Sheet.tsx'
|
||||
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
|
||||
export { Slider, type SliderProps } from './Slider.tsx'
|
||||
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
|
||||
export { TextInput, type TextInputProps } from './TextInput.tsx'
|
||||
export { Toggle, type ToggleProps } from './Toggle.tsx'
|
||||
@@ -14,6 +14,9 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
// Inject CSS Modules into jsdom so tests can assert the sizes the
|
||||
// stylesheets actually declare (for example 44 px hit areas).
|
||||
css: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user