testkit

package module
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

README

nexssp/testkit

Go Reference CI License

Test the real boundary, not a mock of it.

nexssp/testkit is a Go testing toolkit for real HTTP integration tests, black-box E2E tests, SSE streams, action contracts, concurrency, load, chaos, and JSON-RPC/stdio transports.

It uses the standard testing package and standard net/http interfaces. It does not require a third-party mock framework.

What it tests

Target Constructor or package Scope
nexssp/kernel actions testkit.New In-process HTTP integration with mounted actions
Any Go router or handler testkit.NewWithHandler Router and middleware integration
Running local, LAN, container, staging, or production URL testkit.NewE2E Black-box HTTP E2E
JSON-RPC or stdio server testkit/rpc Line-delimited JSON-RPC over an in-memory pipe
Action resilience and concurrency Simulate, Script, Recorder, WithChaos Deterministic and stress-oriented tests

Installation

go get github.com/nexssp/testkit@latest

Quick decision guide

Choose a constructor
// Kernel actions, in memory.
suite := testkit.New(t, GetUser, CreateOrder )

// Any standard net/http.Handler.
suite := testkit.NewWithHandler(t, mux )

// A running process, container, LAN machine, or staging deployment.
suite := testkit.NewE2E(t, "http://127.0.0.1:8080" )
Choose an assertion
Requirement API
One synchronous HTTP request suite.GET(...).Do().ExpectOK()
Decode one response response.Into(&dst)
Inspect nested JSON response.HasField("data.id", value)
Wait for arbitrary state testkit.Eventually(...)
Poll an HTTP JSON endpoint testkit.WaitForJSON(...)
Wait for a named SSE event stream.WaitFor(...)
Wait for an SSE substring stream.WaitForData(...)
Match structured SSE criteria stream.WaitForSSE(...)
Probe all action routes testkit.RunSmokeTests(...)
Test JSON-RPC/stdio testkit/rpc

Cheatsheet

// Immediate HTTP assertion.
suite.GET("/healthz").Do().ExpectOK()

// Request options.
suite.POST("/v1/orders").
    WithJSON(map[string]any{"sku": "A1"}).
    WithBearerToken("token").
    WithTenant("tenant-1").
    Do().
    ExpectCreated()

// Typed response.
var status StatusResponse
suite.GET("/api/status").Do().ExpectOK().Into(&status)

// Eventually consistent HTTP state.
status = testkit.WaitForJSON[StatusResponse](
    t,
    20*time.Second,
    250*time.Millisecond,
    func() (*testkit.Response, error) {
        return suite.GET("/api/status").DoE()
    },
    func(value StatusResponse) bool {
        return value.State == "completed"
    },
)

// Generic eventual condition.
testkit.Eventually(t, 10*time.Second, 100*time.Millisecond, func() bool {
    return service.IsReady()
})

// SSE event matching.
stream := suite.ListenSSE("/api/events")
t.Cleanup(stream.Close)
event := stream.WaitForSSE(t, 20*time.Second, func(event testkit.SSEEvent) bool {
    return event.Event == "docker" && strings.Contains(event.Data, "completed")
})
_ = event

1. Testing kernel actions

testkit.New mounts actions on an in-memory httptest.Server, applies the test context bridge, and registers cleanup with t.Cleanup.

package orders_test

import (
    "context"
    "testing"

    "github.com/nexssp/kernel/action"
    "github.com/nexssp/testkit"
    "github.com/nexssp/transport/thttp"
 )

type UserDTO struct {
    ID    string `json:"id" path:"id"`
    Email string `json:"email"`
}

func TestGetUser(t *testing.T) {
    getUser := action.New("user.get", func(_ context.Context, req UserDTO) (UserDTO, error) {
        return UserDTO{ID: req.ID, Email: "admin@nexss.com"}, nil
    }).Route(thttp.GET("/v1/users/{id}" )).Build()

    suite := testkit.New(t, getUser)
    suite.GET("/v1/users/usr-42").
        Do().
        ExpectOK().
        HasField("id", "usr-42").
        HasField("email", "admin@nexss.com")
}

2. Testing any http.Handler

No kernel dependency is required. NewWithHandler works with the standard library and routers such as Chi, Gin, and Echo.

func TestHealth(t *testing.T ) {
    mux := http.NewServeMux( )
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request ) {
        w.Header().Set("Content-Type", "application/json")
        _, _ = w.Write([]byte(`{"status":"healthy"}`))
    })

    suite := testkit.NewWithHandler(t, mux)
    suite.GET("/healthz").Do().ExpectOK().HasField("status", "healthy")
}

3. Black-box E2E testing

NewE2E uses the same request and assertion API against a running service.

func TestStagingStatus(t *testing.T) {
    suite := testkit.NewE2E(t, "https://staging-api.example.com" )

    suite.GET("/v1/system/status").
        WithHeader("X-Api-Key", os.Getenv("STAGING_API_KEY")).
        Do().
        ExpectOK().
        HasField("environment", "staging")
}

For a local LAN service:

JUMALU_URL=http://192.168.1.42:8080 go test -tags=integration -v ./integration/...

Use E2E tests for the real process, configuration, persistence, Docker, network, and provider boundaries. Keep them separate from fast in-process tests.

4. HTTP requests and assertions

suite.POST("/v1/invoices" ).
    WithQuery("draft", "true").
    WithQueries(map[string]string{"currency": "USD"}).
    WithHeader("X-Trace-ID", "trace-123").
    WithBearerToken("jwt-token").
    WithTenant("tenant-corp").
    WithCookie("session_id", "sess-abc").
    WithJSON(map[string]any{
        "customer": "Acme Corp",
        "amount":   1250.50,
    }).
    Do().
    ExpectCreated()

For multipart uploads:

suite.POST("/v1/documents/upload").
    WithMultipartFile("file", "invoice.pdf", []byte("%PDF-1.4...")).
    Do().
    ExpectCreated()

Available response operations include:

ExpectStatus(code)
ExpectSuccess()
ExpectOK()
ExpectCreated()
ExpectBadRequest()
ExpectUnauthorized()
ExpectForbidden()
ExpectNotFound()
ExpectHeader(name, value)
HasField("data.items.0.sku", "PROD-99")
ExpectArrayLen("data.items", 3)
ContainsString("Acme Corp")
Into(&typedValue)
ExpectErrorKind(kind, message)

HasField uses dot-separated paths and numeric array indexes. Use Into when a typed response is more readable or when the test needs several fields.

DoE returns an error instead of failing the test immediately and is useful in polling functions:

response, err := suite.GET("/healthz").DoE()

5. Asynchronous HTTP and SSE assertions

Eventually

Use Eventually for a condition that is not specifically HTTP or SSE. It evaluates immediately, then waits between attempts. It starts no goroutines.

testkit.Eventually(t, 10*time.Second, 100*time.Millisecond, func() bool {
    return engine.State() == "completed"
})

Use polling only for genuinely asynchronous behavior. Do not use it to hide a deterministic failure.

WaitForJSON

Use WaitForJSON when an HTTP endpoint exposes eventually consistent state. The request factory must build a fresh request for each attempt.

type StatusResponse struct {
    State string `json:"state"`
}

status := testkit.WaitForJSON[StatusResponse](
    t,
    20*time.Second,
    250*time.Millisecond,
    func() (*testkit.Response, error) {
        return suite.GET("/api/status").DoE()
    },
    func(value StatusResponse) bool {
        return value.State == "completed"
    },
)

Request and JSON-decoding errors are retried. If the timeout expires, the last observed error is included in the failure when one exists.

SSE
stream := suite.ListenSSE("/events/telemetry")
t.Cleanup(stream.Close)

suite.POST("/v1/trigger-event", map[string]string{"msg": "ping"}).
    Do().
    ExpectOK()

// Named event.
event := stream.WaitFor(t, "message", 2*time.Second)

// Data substring.
event = stream.WaitForData(t, "ping", 2*time.Second)

// Structured predicate.
event = stream.WaitForSSE(t, 2*time.Second, func(event testkit.SSEEvent) bool {
    return event.Event == "message" && strings.Contains(event.Data, "ping")
})

For MCP-style SSE handshakes:

endpoint := stream.Endpoint(t, 2*time.Second)

For POST plus SSE protocols such as MCP Streamable HTTP or A2A, use ListenSSEWithRequest.

6. Route smoke testing

RunSmokeTests discovers mounted action routes, synthesizes minimal payloads, substitutes path parameters, and fails on unhandled 5xx responses.

func TestAllRoutes(t *testing.T) {
    testkit.RunSmokeTests(t, []action.AnyAction{
        CreateOrder,
        GetOrder,
        ListOrders,
    })
}

A 2xx, 4xx, or other intentional non-5xx response passes. This verifies that endpoints do not panic or fail through an unhandled server error; it does not replace behavioral endpoint tests.

7. Architectural contracts

AssertContracts checks system-wide action invariants such as duplicate names, missing bindings, and malformed API payload types.

testkit.AssertContracts(t, allActions)

8. Deterministic scripting and hook recording

Script supplies deterministic sequential results for retry, circuit-breaker, and fallback tests.

scripted := testkit.Script[int, string](
    testkit.Failure[string](xerr.Unavailable("temporary failure")),
    testkit.Failure[string](xerr.Unavailable("temporary failure")),
    testkit.Success("recovered"),
)

act := action.New("order.settle", scripted).
    Retry(3, action.ConstantBackoff(time.Millisecond)).
    Build()

Recorder captures retry, cache, coalescing, and deduplication events.

rec := new(testkit.Recorder[int, string])

Attach it through the action hook interfaces and assert on the recorded events after execution.

9. Concurrency and thundering-herd tests

Simulate starts concurrent workers behind a synchronized barrier.

var databaseCalls atomic.Int32

fetchPrice := action.New("price.fetch", func(_ context.Context, sku string) (float64, error) {
    databaseCalls.Add(1)
    time.Sleep(20 * time.Millisecond)
    return 199.99, nil
}).Dedup(func(sku string) string { return sku }).Build()

testkit.Simulate(t, fetchPrice, "SKU-1", 50, func(t testing.TB, price float64, err error) {
    if err != nil || price != 199.99 {
        t.Errorf("unexpected result: %v, err=%v", price, err)
    }
})

if got := databaseCalls.Load(); got != 1 {
    t.Fatalf("expected one underlying call, got %d", got)
}

10. Load and latency testing

LoadTest runs in-process traffic and reports request count, RPS, error rate, and latency percentiles.

result := suite.LoadTest(t, testkit.LoadConfig{
    Concurrency: 16,
    Duration:    3 * time.Second,
    Method:      http.MethodGet,
    Path:        "/v1/orders/ord-1",
} )

t.Logf("requests=%d rps=%.2f errors=%.2f%% p50=%v p95=%v p99=%v",
    result.TotalRequests,
    result.RPS,
    result.ErrorRate*100,
    result.P50,
    result.P95,
    result.P99,
)

StartBackgroundLoad is useful while collecting profiles or inspecting metrics:

stop := suite.StartBackgroundLoad(testkit.LoadConfig{
    Concurrency: 8,
    Method:      http.MethodGet,
    Path:        "/v1/orders/ord-1",
} )
defer stop()

Treat load-test thresholds as environment-specific measurements, not universal guarantees.

11. Chaos and fault injection

testkit/chaos injects controlled delay, errors, and panics into action execution.

actions := testkit.WithChaos([]action.AnyAction{CreateOrder}, chaos.Config{
    Enabled:   true,
    ErrorRate: 0.25,
    MaxDelay:  150 * time.Millisecond,
    PanicRate: 0.02,
})

suite := testkit.New(t, actions)
suite.POST("/v1/orders", map[string]any{"sku": "A"}).
    Do().
    ExpectSuccess()

Use chaos tests to verify retry, timeout, circuit-breaker, panic-recovery, and error-boundary behavior. Keep randomness bounded and use assertions that tolerate the configured fault rate.

12. JSON-RPC and stdio transports

Use testkit/rpc for line-delimited JSON-RPC transports such as MCP stdio. It uses an in-memory net.Pipe and a real JSON-RPC client.

func TestMCPStdio(t *testing.T) {
    client := rpc.DialJSONRPC(t, func(ctx context.Context, in io.Reader, out io.Writer) error {
        return mcpServer.Serve(ctx, in, out)
    })

    response := client.Call("tools/list", nil, 1)

    var result struct {
        Tools []struct {
            Name string `json:"name"`
        } `json:"tools"`
    }
    response.BindResult(t, &result)

    if len(result.Tools) == 0 {
        t.Fatal("expected at least one tool")
    }
}

The RPC client supports:

client.Call(method, params, id)
client.Notify(method, params)
client.CallRaw(rawJSON)
response.BindResult(t, &destination)
response.Error

Testing guidance

Keep tests in layers:

  1. Unit tests for pure functions and deterministic action behavior.

  2. In-process integration tests with New or NewWithHandler.

  3. Black-box E2E tests with NewE2E against a real process or deployment.

  4. Load and chaos tests as explicit, separately named test groups.

Use build tags or environment gates for tests requiring Docker, external providers, a LAN service, or a running binary:

go test ./...
go test -race ./...
go test -tags=integration -v ./integration/...

Avoid putting credentials in source code or logs. Use environment variables and explicit test configuration. A trusted-LAN E2E test is not an authorization test and must not be presented as one.

Project status and performance claims

testkit provides testing primitives and measurement tools. It does not guarantee that an application is production-ready, zero-allocation on every path, or capable of a particular scale. Those properties depend on the application, dependencies, deployment, workload, and measured results.

Use benchmarks, race tests, load tests, and production-like E2E tests to establish claims for a specific system.

License

Apache License 2.0. See LICENSE for details.

Documentation

Overview

Package testkit provides deterministic, fluent test harnesses for both nexssp actions and standard Go http.Handler implementations.

Index

Constants

View Source
const (
	HeaderTenantID = "X-Tenant-ID"
)

Variables

This section is empty.

Functions

func AssertContracts

func AssertContracts(t *testing.T, actions []action.AnyAction)

AssertContracts verifies architectural invariants across all registered actions. Catches duplicate action names, orphaned actions without routes/hooks, and malformed DTOs.

func BenchAction

func BenchAction[Req, Res any](b *testing.B, act *action.BuiltAction[Req, Res], req Req)

BenchAction benchmarks a built action's pure execution speed without transport overhead.

func BenchHTTP

func BenchHTTP(b *testing.B, s *Suite, method, path string, payload any)

BenchHTTP benchmarks the complete HTTP stack over in-memory sockets.

func Eventually added in v0.3.0

func Eventually(t testing.TB, timeout, interval time.Duration, condition func() bool)

Eventually repeatedly evaluates condition until it returns true or timeout expires. The condition is evaluated immediately before waiting for the first interval. It runs synchronously on the caller's goroutine and Eventually starts no goroutines.

func MustError

func MustError[Req, Res any](t testing.TB, fn action.Fn[Req, Res], req Req, want error)

func MustExecute

func MustExecute[Req, Res any](t testing.TB, fn action.Fn[Req, Res], req Req) Res

func RunSmokeTests

func RunSmokeTests(t *testing.T, actions []action.AnyAction)

func Script

func Script[Req, Res any](results ...Result[Res]) action.Fn[Req, Res]

Script returns a thread-safe deterministic action function.

func Simulate

func Simulate[Req, Res any](
	t testing.TB,
	act *action.BuiltAction[Req, Res],
	req Req,
	concurrency int,
	assertFn func(t testing.TB, res Res, err error),
)

Simulate executes an action concurrently across N workers using a synchronized barrier to verify thread-safety, race conditions, and singleflight deduplication.

func WaitForJSON added in v0.3.0

func WaitForJSON[T any](
	t testing.TB,
	timeout, interval time.Duration,
	request func() (*Response, error),
	ready func(T) bool,
) T

WaitForJSON repeats request until it returns a decodable JSON value accepted by ready, or timeout expires. request must build a fresh request on every call. Transient request and JSON-decoding errors are retried; the last error is included in the timeout failure when one is available.

This is intended for eventually consistent HTTP APIs. For ordinary one-shot assertions, use suite.GET(...).Do().Into(...) instead.

func WithChaos

func WithChaos(actions []action.AnyAction, cfg chaos.Config) []action.AnyAction

WithChaos wraps provided actions with chaos fault injection.

Types

type LoadConfig

type LoadConfig struct {
	Concurrency int
	Duration    time.Duration
	Method      string
	Path        string
	Payload     any
}

type LoadResult

type LoadResult struct {
	TotalRequests int64
	Errors        int64
	ErrorRate     float64
	RPS           float64
	P50           time.Duration
	P95           time.Duration
	P99           time.Duration
}

type Recorder

type Recorder[Req, Res any] struct {
	Retries      []RetryEvent[Req]
	CacheHits    int
	CacheMisses  int
	Coalesced    int
	Deduplicated int
	// contains filtered or unexported fields
}

Recorder implements action.HookDispatcher with full goroutine concurrency safety.

func (*Recorder[Req, Res]) OnCacheHit

func (r *Recorder[Req, Res]) OnCacheHit(context.Context, Req, Res)

func (*Recorder[Req, Res]) OnCacheMiss

func (r *Recorder[Req, Res]) OnCacheMiss(context.Context, Req)

func (*Recorder[Req, Res]) OnCoalesced

func (r *Recorder[Req, Res]) OnCoalesced(context.Context, Req)

func (*Recorder[Req, Res]) OnDeduplicated

func (r *Recorder[Req, Res]) OnDeduplicated(context.Context, Req)

func (*Recorder[Req, Res]) OnRetry

func (r *Recorder[Req, Res]) OnRetry(_ context.Context, req Req, attempt int, err error)

type Request

type Request struct {
	// contains filtered or unexported fields
}

func (*Request) Do

func (r *Request) Do() *Response

Do executes the request and immediately fails the test if an error occurs.

func (*Request) DoContext

func (r *Request) DoContext(ctx context.Context) (*Response, error)

DoContext executes the HTTP request with the provided context and returns an error without calling t.Fatalf (goroutine safe).

func (*Request) DoE

func (r *Request) DoE() (*Response, error)

DoE executes the HTTP request using the request's configured context.

func (*Request) WithBearerToken

func (r *Request) WithBearerToken(token string) *Request

func (*Request) WithContext

func (r *Request) WithContext(ctx context.Context) *Request

func (*Request) WithCookie

func (r *Request) WithCookie(name, value string) *Request

func (*Request) WithForm

func (r *Request) WithForm(data map[string]string) *Request

func (*Request) WithHeader

func (r *Request) WithHeader(k, v string) *Request

func (*Request) WithJSON

func (r *Request) WithJSON(v any) *Request

func (*Request) WithMultipartFile

func (r *Request) WithMultipartFile(fieldName, filename string, content []byte) *Request

func (*Request) WithQueries

func (r *Request) WithQueries(params map[string]string) *Request

func (*Request) WithQuery

func (r *Request) WithQuery(key, value string) *Request

func (*Request) WithTenant

func (r *Request) WithTenant(tenantID string) *Request

type Response

type Response struct {
	// contains filtered or unexported fields
}

func (*Response) Body

func (c *Response) Body() []byte

func (*Response) BodyString

func (c *Response) BodyString() string

func (*Response) ContainsString

func (c *Response) ContainsString(substr string) *Response

func (*Response) ExpectArrayLen

func (c *Response) ExpectArrayLen(path string, n int) *Response

func (*Response) ExpectBadRequest

func (c *Response) ExpectBadRequest() *Response

func (*Response) ExpectCreated

func (c *Response) ExpectCreated() *Response

func (*Response) ExpectErrorKind

func (c *Response) ExpectErrorKind(kind xerr.Kind, message string) *Response

func (*Response) ExpectForbidden

func (c *Response) ExpectForbidden() *Response

func (*Response) ExpectHeader

func (c *Response) ExpectHeader(key, value string) *Response

func (*Response) ExpectNotFound

func (c *Response) ExpectNotFound() *Response

func (*Response) ExpectOK

func (c *Response) ExpectOK() *Response

func (*Response) ExpectStatus

func (c *Response) ExpectStatus(code int) *Response

func (*Response) ExpectSuccess

func (c *Response) ExpectSuccess() *Response

func (*Response) ExpectUnauthorized

func (c *Response) ExpectUnauthorized() *Response

func (*Response) HasField

func (c *Response) HasField(path string, expected any) *Response

func (*Response) Header

func (c *Response) Header(k string) string

func (*Response) Into

func (c *Response) Into(v any) *Response

func (*Response) Status

func (c *Response) Status() int

type Result

type Result[Res any] struct {
	Value Res
	Err   error
}

func Failure

func Failure[Res any](err error) Result[Res]

func Success

func Success[Res any](value Res) Result[Res]

type RetryEvent

type RetryEvent[Req any] struct {
	Request Req
	Attempt int
	Err     error
}

type SSEEvent

type SSEEvent struct {
	Event string
	Data  string
}

type StreamCapture

type StreamCapture struct {
	// contains filtered or unexported fields
}

func (*StreamCapture) Close

func (sc *StreamCapture) Close()

func (*StreamCapture) Endpoint added in v0.2.0

func (sc *StreamCapture) Endpoint(t testing.TB, timeout time.Duration) string

Endpoint conveniently extracts an MCP SSE endpoint event.

func (*StreamCapture) WaitFor

func (sc *StreamCapture) WaitFor(t testing.TB, eventName string, timeout time.Duration) SSEEvent

func (*StreamCapture) WaitForData added in v0.2.0

func (sc *StreamCapture) WaitForData(t testing.TB, substr string, timeout time.Duration) SSEEvent

func (*StreamCapture) WaitForSSE added in v0.3.0

func (sc *StreamCapture) WaitForSSE(t testing.TB, timeout time.Duration, match func(SSEEvent) bool) SSEEvent

WaitForSSE waits for the first buffered SSE event accepted by match. Events that do not match are discarded, just like StreamCapture.WaitFor and WaitForData. A nil match predicate is rejected to avoid an accidental match-all wait; use WaitFor with an empty event name when matching any event.

func (*StreamCapture) WaitForSSEData added in v0.3.0

func (sc *StreamCapture) WaitForSSEData(t testing.TB, substr string, timeout time.Duration) SSEEvent

WaitForSSEData waits for an SSE event whose data contains substr. It is the predicate equivalent of StreamCapture.WaitForData and is useful when a test needs the event name as well as a data substring.

type Suite

type Suite struct {
	T      testing.TB
	Server *httptest.Server
	// contains filtered or unexported fields
}

Suite wraps an in-memory or live HTTP server with fluent test assertions.

func New

func New(t testing.TB, providers ...any) *Suite

New flattens nexssp actions and mounts them onto an in-memory transport.

func NewE2E

func NewE2E(t testing.TB, baseURL string) *Suite

NewE2E initializes the suite against a live, external URL.

func NewWithHandler

func NewWithHandler(t testing.TB, h http.Handler) *Suite

NewWithHandler initializes a test suite against ANY standard Go http.Handler.

func (*Suite) DELETE

func (s *Suite) DELETE(path string, body ...any) *Request

func (*Suite) GET

func (s *Suite) GET(path string, body ...any) *Request

func (*Suite) ListenSSE

func (s *Suite) ListenSSE(path string) *StreamCapture

ListenSSE connects to an SSE endpoint using GET.

func (*Suite) ListenSSEWithRequest added in v0.2.0

func (s *Suite) ListenSSEWithRequest(t testing.TB, req *http.Request) *StreamCapture

ListenSSEWithRequest connects to an SSE endpoint using a custom HTTP request. This supports POST + SSE patterns such as MCP Streamable HTTP and A2A.

func (*Suite) LoadTest

func (s *Suite) LoadTest(t *testing.T, cfg LoadConfig) LoadResult

LoadTest executes in-process stress tests, properly propagating cancellation to in-flight requests.

func (*Suite) PATCH

func (s *Suite) PATCH(path string, body ...any) *Request

func (*Suite) POST

func (s *Suite) POST(path string, body ...any) *Request

func (*Suite) PUT

func (s *Suite) PUT(path string, body ...any) *Request

func (*Suite) Request

func (s *Suite) Request(method, path string) *Request

func (*Suite) ResetHeaders

func (s *Suite) ResetHeaders() *Suite

func (*Suite) StartBackgroundLoad

func (s *Suite) StartBackgroundLoad(cfg LoadConfig) func()

StartBackgroundLoad generates background load, correctly checking for URL/request errors.

func (*Suite) WithCookie

func (s *Suite) WithCookie(name, value string) *Suite

func (*Suite) WithGlobalBearerToken

func (s *Suite) WithGlobalBearerToken(token string) *Suite

func (*Suite) WithGlobalHeader

func (s *Suite) WithGlobalHeader(key, value string) *Suite

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL