Some checks failed
CI / Build and Test (push) Has been cancelled
Add 30s keepalive pings to prevent proxy/CDN idle timeouts from killing SSE connections silently. Track lastSeenAt on each SSE connect attempt so Datastar's retry signals keep contexts alive through the reaper.
614 lines
17 KiB
Go
614 lines
17 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")
|
|
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 TestFormatDuration(t *testing.T) {
|
|
tests := []struct {
|
|
d time.Duration
|
|
want string
|
|
}{
|
|
{200 * time.Millisecond, "200ms"},
|
|
{1 * time.Second, "1000ms"},
|
|
{50 * time.Millisecond, "50ms"},
|
|
}
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, formatDuration(tt.d))
|
|
}
|
|
}
|
|
|
|
func TestBuildAttrKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
event string
|
|
opts triggerOpts
|
|
want string
|
|
}{
|
|
{"bare event", "click", triggerOpts{}, "on:click"},
|
|
{"debounce only", "change", triggerOpts{debounce: 200 * time.Millisecond}, "on:change__debounce.200ms"},
|
|
{"throttle only", "scroll", triggerOpts{throttle: 100 * time.Millisecond}, "on:scroll__throttle.100ms"},
|
|
{"window only", "keydown", triggerOpts{window: true}, "on:keydown__window"},
|
|
{"debounce + window", "input", triggerOpts{debounce: 300 * time.Millisecond, window: true}, "on:input__debounce.300ms__window"},
|
|
{"throttle + window", "scroll", triggerOpts{throttle: 500 * time.Millisecond, window: true}, "on:scroll__throttle.500ms__window"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, buildAttrKey(tt.event, &tt.opts))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWithDebounce(t *testing.T) {
|
|
var trigger *actionTrigger
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
trigger = c.Action(func() {})
|
|
c.View(func() h.H {
|
|
return h.Button(trigger.OnClick(WithDebounce(300 * time.Millisecond)))
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "data-on:click__debounce.300ms")
|
|
assert.Contains(t, body, "/_action/"+trigger.id)
|
|
}
|
|
|
|
func TestWithThrottle(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.OnScroll(WithThrottle(100 * time.Millisecond)))
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "data-on:scroll__throttle.100ms")
|
|
assert.Contains(t, body, "/_action/"+trigger.id)
|
|
}
|
|
|
|
func TestWithDebounceOnChange(t *testing.T) {
|
|
var trigger *actionTrigger
|
|
v := New()
|
|
v.Page("/", func(c *Context) {
|
|
trigger = c.Action(func() {})
|
|
c.View(func() h.H {
|
|
return h.Input(trigger.OnChange(WithDebounce(200 * time.Millisecond)))
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
v.mux.ServeHTTP(w, req)
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "data-on:change__debounce.200ms")
|
|
assert.Contains(t, body, "/_action/"+trigger.id)
|
|
}
|
|
|
|
func TestDebounceWithWindow(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", WithDebounce(150*time.Millisecond), 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__debounce.150ms__window")
|
|
}
|
|
|
|
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)
|
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
|
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)
|
|
c.createdAt = time.Now().Add(-3 * time.Hour)
|
|
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)
|
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
|
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 TestReaperRespectsLastSeenAt(t *testing.T) {
|
|
v := New()
|
|
c := newContext("seen-1", "/", v)
|
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
|
// Disconnected 20 min ago, but client retried (lastSeenAt) 2 min ago
|
|
dc := time.Now().Add(-20 * time.Minute)
|
|
c.sseDisconnectedAt.Store(&dc)
|
|
seen := time.Now().Add(-2 * time.Minute)
|
|
c.lastSeenAt.Store(&seen)
|
|
v.registerCtx(c)
|
|
|
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
|
|
|
_, err := v.getCtx("seen-1")
|
|
assert.NoError(t, err, "context with recent lastSeenAt should survive suspend threshold")
|
|
assert.False(t, c.suspended.Load(), "context should not be suspended")
|
|
}
|
|
|
|
func TestReaperFallsBackWithoutLastSeenAt(t *testing.T) {
|
|
v := New()
|
|
c := newContext("noseen-1", "/", v)
|
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
|
dc := time.Now().Add(-20 * time.Minute)
|
|
c.sseDisconnectedAt.Store(&dc)
|
|
// no lastSeenAt set — should fall back to sseDisconnectedAt
|
|
v.registerCtx(c)
|
|
|
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
|
|
|
got, err := v.getCtx("noseen-1")
|
|
assert.NoError(t, err, "context should still be in registry (suspended, not reaped)")
|
|
assert.True(t, got.suspended.Load(), "context should be suspended using sseDisconnectedAt fallback")
|
|
}
|
|
|
|
func TestReaperReapsWithStaleLastSeenAt(t *testing.T) {
|
|
v := New()
|
|
c := newContext("stale-seen-1", "/", v)
|
|
c.createdAt = time.Now().Add(-3 * time.Hour)
|
|
dc := time.Now().Add(-2 * time.Hour)
|
|
c.sseDisconnectedAt.Store(&dc)
|
|
// lastSeenAt is also old — beyond TTL
|
|
seen := time.Now().Add(-90 * time.Minute)
|
|
c.lastSeenAt.Store(&seen)
|
|
c.suspended.Store(true)
|
|
v.registerCtx(c)
|
|
|
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
|
|
|
_, err := v.getCtx("stale-seen-1")
|
|
assert.Error(t, err, "context with stale lastSeenAt beyond TTL should be reaped")
|
|
}
|
|
|
|
func TestLastSeenAtUpdatedOnSSEConnect(t *testing.T) {
|
|
v := New()
|
|
c := newContext("seen-sse-1", "/", v)
|
|
v.registerCtx(c)
|
|
|
|
assert.Nil(t, c.lastSeenAt.Load(), "lastSeenAt should be nil before SSE connect")
|
|
|
|
// Simulate what the SSE handler does after getCtx
|
|
now := time.Now()
|
|
c.lastSeenAt.Store(&now)
|
|
|
|
got := c.lastSeenAt.Load()
|
|
assert.NotNil(t, got, "lastSeenAt should be set after SSE connect")
|
|
assert.WithinDuration(t, now, *got, time.Second)
|
|
}
|
|
|
|
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")
|
|
}
|