All checks were successful
CI / Build and Test (push) Successful in 1m22s
Contexts that lose their SSE connection now pass through a suspended state before being fully reaped. Suspended contexts keep their shell (ID, route, CSRF token) but free page resources. On reconnect, the page init function is re-run for a seamless resume. Contexts past the TTL trigger a client-side reload instead of a silent dead page. Configurable via ContextSuspendAfter (default 15m) and ContextTTL (default 1h).
437 lines
12 KiB
Go
437 lines
12 KiB
Go
package via
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ryanhamamura/via/h"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestPageRoute(t *testing.T) {
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
c.View(func() h.H {
|
|
return h.Div(h.Text("Hello Via!"))
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Hello Via!")
|
|
assert.Contains(t, w.Body.String(), "<!doctype html>")
|
|
}
|
|
|
|
func TestDatastarJS(t *testing.T) {
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
c.View(func() h.H { return h.Div() })
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/_datastar.js", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
|
|
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
|
|
}
|
|
|
|
func TestCustomDatastarContent(t *testing.T) {
|
|
customScript := []byte("// Custom Datastar Script")
|
|
v := New()
|
|
v.Config(Options{
|
|
DatastarContent: customScript,
|
|
})
|
|
v.Page("/", func(c *Context) {
|
|
c.View(func() h.H { return h.Div() })
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/_datastar.js", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
|
|
assert.Contains(t, w.Body.String(), "Custom Datastar Script")
|
|
}
|
|
|
|
func TestCustomDatastarPath(t *testing.T) {
|
|
v := New()
|
|
v.Config(Options{
|
|
DatastarPath: "/assets/datastar.js",
|
|
})
|
|
v.Page("/test", func(c *Context) {
|
|
c.View(func() h.H { return h.Div() })
|
|
})
|
|
|
|
// Custom path should serve the script
|
|
req := httptest.NewRequest("GET", "/assets/datastar.js", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
|
|
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
|
|
|
|
// Page should reference the custom path in script tag
|
|
req2 := httptest.NewRequest("GET", "/test", nil)
|
|
w2 := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w2, req2)
|
|
assert.Contains(t, w2.Body.String(), `src="/assets/datastar.js"`)
|
|
}
|
|
|
|
func TestSignal(t *testing.T) {
|
|
var sig *signal
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
sig = c.Signal("test")
|
|
c.View(func() h.H { return h.Div() })
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, "test", sig.String())
|
|
}
|
|
|
|
func TestAction(t *testing.T) {
|
|
var trigger *actionTrigger
|
|
var sig *signal
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
trigger = c.Action(func() {})
|
|
sig = c.Signal("value")
|
|
c.View(func() h.H {
|
|
return h.Div(
|
|
h.Button(trigger.OnClick()),
|
|
h.Input(trigger.OnChange()),
|
|
h.Input(trigger.OnKeyDown("Enter")),
|
|
h.Button(trigger.OnClick(WithSignal(sig, "test"))),
|
|
h.Button(trigger.OnClick(WithSignalInt(sig, 42))),
|
|
)
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "data-on:click")
|
|
assert.Contains(t, body, "data-on:change__debounce.200ms")
|
|
assert.Contains(t, body, "data-on:keydown")
|
|
assert.Contains(t, body, "/_action/")
|
|
}
|
|
|
|
func TestEventTypes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
attr string
|
|
buildEl func(trigger *actionTrigger) h.H
|
|
}{
|
|
{"OnSubmit", "data-on:submit", func(tr *actionTrigger) h.H { return h.Form(tr.OnSubmit()) }},
|
|
{"OnInput", "data-on:input", func(tr *actionTrigger) h.H { return h.Input(tr.OnInput()) }},
|
|
{"OnFocus", "data-on:focus", func(tr *actionTrigger) h.H { return h.Input(tr.OnFocus()) }},
|
|
{"OnBlur", "data-on:blur", func(tr *actionTrigger) h.H { return h.Input(tr.OnBlur()) }},
|
|
{"OnMouseEnter", "data-on:mouseenter", func(tr *actionTrigger) h.H { return h.Div(tr.OnMouseEnter()) }},
|
|
{"OnMouseLeave", "data-on:mouseleave", func(tr *actionTrigger) h.H { return h.Div(tr.OnMouseLeave()) }},
|
|
{"OnScroll", "data-on:scroll", func(tr *actionTrigger) h.H { return h.Div(tr.OnScroll()) }},
|
|
{"OnDblClick", "data-on:dblclick", func(tr *actionTrigger) h.H { return h.Div(tr.OnDblClick()) }},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var trigger *actionTrigger
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
trigger = c.Action(func() {})
|
|
c.View(func() h.H { return tt.buildEl(trigger) })
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, tt.attr)
|
|
assert.Contains(t, body, "/_action/"+trigger.id)
|
|
})
|
|
}
|
|
|
|
t.Run("WithSignal", func(t *testing.T) {
|
|
var trigger *actionTrigger
|
|
var sig *signal
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
trigger = c.Action(func() {})
|
|
sig = c.Signal("val")
|
|
c.View(func() h.H {
|
|
return h.Div(trigger.OnDblClick(WithSignal(sig, "x")))
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "data-on:dblclick")
|
|
assert.Contains(t, body, "$"+sig.ID()+"='x'")
|
|
})
|
|
}
|
|
|
|
func TestOnKeyDownWithWindow(t *testing.T) {
|
|
var trigger *actionTrigger
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
trigger = c.Action(func() {})
|
|
c.View(func() h.H {
|
|
return h.Div(trigger.OnKeyDown("Enter", WithWindow()))
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "data-on:keydown__window")
|
|
assert.Contains(t, body, "evt.key==='Enter'")
|
|
}
|
|
|
|
func TestOnKeyDownMap(t *testing.T) {
|
|
t.Run("multiple bindings with different actions", func(t *testing.T) {
|
|
var move, shoot *actionTrigger
|
|
var dir *signal
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
dir = c.Signal("none")
|
|
move = c.Action(func() {})
|
|
shoot = c.Action(func() {})
|
|
c.View(func() h.H {
|
|
return h.Div(
|
|
OnKeyDownMap(
|
|
KeyBind("w", move, WithSignal(dir, "up")),
|
|
KeyBind("ArrowUp", move, WithSignal(dir, "up"), WithPreventDefault()),
|
|
KeyBind(" ", shoot, WithPreventDefault()),
|
|
),
|
|
)
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
|
|
// Single attribute, window-scoped
|
|
assert.Contains(t, body, "data-on:keydown__window")
|
|
|
|
// Key dispatching
|
|
assert.Contains(t, body, "evt.key==='w'")
|
|
assert.Contains(t, body, "evt.key==='ArrowUp'")
|
|
assert.Contains(t, body, "evt.key===' '")
|
|
|
|
// Different actions referenced
|
|
assert.Contains(t, body, "/_action/"+move.id)
|
|
assert.Contains(t, body, "/_action/"+shoot.id)
|
|
|
|
// preventDefault only on ArrowUp and space branches
|
|
assert.Contains(t, body, "evt.key==='ArrowUp' ? (evt.preventDefault()")
|
|
assert.Contains(t, body, "evt.key===' ' ? (evt.preventDefault()")
|
|
|
|
// 'w' branch should NOT have preventDefault
|
|
assert.NotContains(t, body, "evt.key==='w' ? (evt.preventDefault()")
|
|
})
|
|
|
|
t.Run("WithSignal per binding", func(t *testing.T) {
|
|
var move *actionTrigger
|
|
var dir *signal
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
dir = c.Signal("none")
|
|
move = c.Action(func() {})
|
|
c.View(func() h.H {
|
|
return h.Div(
|
|
OnKeyDownMap(
|
|
KeyBind("w", move, WithSignal(dir, "up")),
|
|
KeyBind("s", move, WithSignal(dir, "down")),
|
|
),
|
|
)
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
|
|
assert.Contains(t, body, "$"+dir.ID()+"='up'")
|
|
assert.Contains(t, body, "$"+dir.ID()+"='down'")
|
|
})
|
|
|
|
t.Run("empty bindings returns nil", func(t *testing.T) {
|
|
result := OnKeyDownMap()
|
|
assert.Nil(t, result)
|
|
})
|
|
}
|
|
|
|
func TestConfig(t *testing.T) {
|
|
v := New()
|
|
v.Config(Options{DocumentTitle: "Test"})
|
|
assert.Equal(t, "Test", v.cfg.DocumentTitle)
|
|
}
|
|
|
|
func TestPage_PanicsOnNoView(t *testing.T) {
|
|
assert.Panics(t, func() {
|
|
v := New()
|
|
v.Page("/", func(c *Context) {})
|
|
})
|
|
}
|
|
|
|
func TestReaperCleansOrphanedContexts(t *testing.T) {
|
|
v := New()
|
|
c := newContext("orphan-1", "/", v)
|
|
c.createdAt = time.Now().Add(-time.Minute) // created 1 min ago
|
|
v.registerCtx(c)
|
|
|
|
_, err := v.getCtx("orphan-1")
|
|
assert.NoError(t, err)
|
|
|
|
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
|
|
|
|
_, err = v.getCtx("orphan-1")
|
|
assert.Error(t, err, "orphaned context should have been reaped")
|
|
}
|
|
|
|
func TestReaperSuspendsContext(t *testing.T) {
|
|
v := New()
|
|
c := newContext("suspend-1", "/", v)
|
|
dc := time.Now().Add(-20 * time.Minute)
|
|
c.sseDisconnectedAt.Store(&dc)
|
|
v.registerCtx(c)
|
|
|
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
|
|
|
got, err := v.getCtx("suspend-1")
|
|
assert.NoError(t, err, "suspended context should still be in registry")
|
|
assert.True(t, got.suspended.Load(), "context should be marked suspended")
|
|
}
|
|
|
|
func TestReaperReapsAfterTTL(t *testing.T) {
|
|
v := New()
|
|
c := newContext("reap-1", "/", v)
|
|
dc := time.Now().Add(-2 * time.Hour)
|
|
c.sseDisconnectedAt.Store(&dc)
|
|
c.suspended.Store(true)
|
|
v.registerCtx(c)
|
|
|
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
|
|
|
_, err := v.getCtx("reap-1")
|
|
assert.Error(t, err, "context past TTL should have been reaped")
|
|
}
|
|
|
|
func TestReaperIgnoresAlreadySuspended(t *testing.T) {
|
|
v := New()
|
|
c := newContext("already-sus-1", "/", v)
|
|
dc := time.Now().Add(-20 * time.Minute)
|
|
c.sseDisconnectedAt.Store(&dc)
|
|
c.suspended.Store(true)
|
|
// give it a fresh pageStopChan so we can verify it's not re-closed
|
|
c.pageStopChan = make(chan struct{})
|
|
v.registerCtx(c)
|
|
|
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
|
|
|
got, err := v.getCtx("already-sus-1")
|
|
assert.NoError(t, err, "already-suspended context within TTL should survive")
|
|
assert.True(t, got.suspended.Load())
|
|
// pageStopChan should still be open (not re-suspended)
|
|
select {
|
|
case <-got.pageStopChan:
|
|
t.Fatal("pageStopChan was closed — context was re-suspended")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestReaperIgnoresConnectedContexts(t *testing.T) {
|
|
v := New()
|
|
c := newContext("connected-1", "/", v)
|
|
c.createdAt = time.Now().Add(-time.Minute)
|
|
c.sseConnected.Store(true)
|
|
v.registerCtx(c)
|
|
|
|
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
|
|
|
|
_, err := v.getCtx("connected-1")
|
|
assert.NoError(t, err, "connected context should survive reaping")
|
|
}
|
|
|
|
func TestReaperDisabledWithNegativeTTL(t *testing.T) {
|
|
v := New()
|
|
v.cfg.ContextTTL = -1
|
|
v.startReaper()
|
|
assert.Nil(t, v.reaperStop, "reaper should not start with negative TTL")
|
|
}
|
|
|
|
func TestCleanupCtxIdempotent(t *testing.T) {
|
|
v := New()
|
|
c := newContext("idempotent-1", "/", v)
|
|
v.registerCtx(c)
|
|
|
|
assert.NotPanics(t, func() {
|
|
v.cleanupCtx(c)
|
|
v.cleanupCtx(c)
|
|
})
|
|
|
|
_, err := v.getCtx("idempotent-1")
|
|
assert.Error(t, err, "context should be removed after cleanup")
|
|
}
|
|
|
|
func TestDevModeRemovePersistedFix(t *testing.T) {
|
|
v := New()
|
|
v.cfg.DevMode = true
|
|
|
|
dir := filepath.Join(t.TempDir(), ".via", "devmode")
|
|
p := filepath.Join(dir, "ctx.json")
|
|
assert.NoError(t, os.MkdirAll(dir, 0755))
|
|
|
|
// Write a persisted context
|
|
ctxRegMap := map[string]string{"test-ctx-1": "/"}
|
|
f, err := os.Create(p)
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, json.NewEncoder(f).Encode(ctxRegMap))
|
|
f.Close()
|
|
|
|
// Patch devModeRemovePersisted to use our temp path by calling it
|
|
// directly — we need to override the path. Instead, test via the
|
|
// actual function by temporarily changing the working dir.
|
|
origDir, _ := os.Getwd()
|
|
assert.NoError(t, os.Chdir(t.TempDir()))
|
|
defer os.Chdir(origDir)
|
|
|
|
// Re-create the structure in the temp dir
|
|
assert.NoError(t, os.MkdirAll(filepath.Join(".via", "devmode"), 0755))
|
|
p2 := filepath.Join(".via", "devmode", "ctx.json")
|
|
f2, _ := os.Create(p2)
|
|
json.NewEncoder(f2).Encode(map[string]string{"test-ctx-1": "/"})
|
|
f2.Close()
|
|
|
|
c := newContext("test-ctx-1", "/", v)
|
|
v.devModeRemovePersisted(c)
|
|
|
|
// Read back and verify
|
|
f3, err := os.Open(p2)
|
|
assert.NoError(t, err)
|
|
defer f3.Close()
|
|
var result map[string]string
|
|
assert.NoError(t, json.NewDecoder(f3).Decode(&result))
|
|
assert.Empty(t, result, "persisted context should be removed")
|
|
}
|