Script, GH action to check that all go files compile, and any tests pass. (#16)
This commit is contained in:
29
.github/workflows/ci.yml
vendored
Normal file
29
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
name: Build and Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25.4'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Run CI checks
|
||||||
|
run: bash ./ci-check.sh
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -36,3 +36,13 @@ go.work.sum
|
|||||||
|
|
||||||
# Air artifacts
|
# Air artifacts
|
||||||
*tmp/
|
*tmp/
|
||||||
|
|
||||||
|
# binaries
|
||||||
|
internal/examples/chatroom/chatroom
|
||||||
|
internal/examples/counter/counter
|
||||||
|
internal/examples/countercomp/countercomp
|
||||||
|
internal/examples/greeter/greeter
|
||||||
|
internal/examples/livereload/livereload
|
||||||
|
internal/examples/plugins/plugins
|
||||||
|
internal/examples/realtimechart/realtimechart
|
||||||
|
internal/examples/picocss/picocss
|
||||||
56
ci-check.sh
Executable file
56
ci-check.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "== CI: Format code =="
|
||||||
|
go fmt ./...
|
||||||
|
echo "OK: formatting complete"
|
||||||
|
|
||||||
|
echo "== CI: Run go vet =="
|
||||||
|
if ! go vet ./...; then
|
||||||
|
echo "ERROR: go vet failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: go vet passed"
|
||||||
|
|
||||||
|
echo "== CI: Build all packages =="
|
||||||
|
if ! go build ./...; then
|
||||||
|
echo "ERROR: go build ./... failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: packages built"
|
||||||
|
|
||||||
|
echo "== CI: Build example apps under internal/examples =="
|
||||||
|
if [ -d "internal/examples" ]; then
|
||||||
|
count=0
|
||||||
|
while IFS= read -r -d '' mainfile; do
|
||||||
|
dir="$(dirname "$mainfile")"
|
||||||
|
echo "Building $dir"
|
||||||
|
if ! (cd "$dir" && go build); then
|
||||||
|
echo "ERROR: example build failed: $dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
count=$((count + 1))
|
||||||
|
done < <(find internal/examples -type f -name "main.go" -print0)
|
||||||
|
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
echo "NOTE: no example main.go files found under internal/examples"
|
||||||
|
else
|
||||||
|
echo "OK: built $count example(s)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "NOTE: internal/examples not found, skipping example builds"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== CI: Run tests =="
|
||||||
|
if ! go test ./... 2>&1 | grep -v '\[no test files\]'; then
|
||||||
|
echo "ERROR: tests failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SUCCESS: All checks passed."
|
||||||
|
exit 0
|
||||||
13
context.go
13
context.go
@@ -273,13 +273,17 @@ func (c *Context) SyncElements(elem h.H) {
|
|||||||
// SyncSignals pushes the current signal changes to the browser immediately
|
// SyncSignals pushes the current signal changes to the browser immediately
|
||||||
// over the live SSE event stream.
|
// over the live SSE event stream.
|
||||||
func (c *Context) SyncSignals() {
|
func (c *Context) SyncSignals() {
|
||||||
sse := c.getSSE()
|
patchChan := c.getPatchChan()
|
||||||
if sse == nil {
|
if patchChan == nil {
|
||||||
c.app.logWarn(c, "signals out of sync: no sse stream")
|
c.app.logWarn(c, "signals out of sync: no sse stream")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updatedSigs := make(map[string]any)
|
updatedSigs := make(map[string]any)
|
||||||
for id, sig := range c.signals {
|
|
||||||
|
c.signals.Range(func(idAny, val any) bool {
|
||||||
|
// We know the types.
|
||||||
|
sig, _ := val.(*signal) // adjust *Signal to your actual signal type
|
||||||
|
id, _ := val.(string)
|
||||||
if sig.err != nil {
|
if sig.err != nil {
|
||||||
c.app.logWarn(c, "signal out of sync'%s': %v", sig.id, sig.err)
|
c.app.logWarn(c, "signal out of sync'%s': %v", sig.id, sig.err)
|
||||||
}
|
}
|
||||||
@@ -287,7 +291,8 @@ func (c *Context) SyncSignals() {
|
|||||||
updatedSigs[id] = fmt.Sprintf("%v", sig.v)
|
updatedSigs[id] = fmt.Sprintf("%v", sig.v)
|
||||||
sig.changed = false
|
sig.changed = false
|
||||||
}
|
}
|
||||||
}
|
return true // continue iteration
|
||||||
|
})
|
||||||
if len(updatedSigs) != 0 {
|
if len(updatedSigs) != 0 {
|
||||||
outgoingSignals, _ := json.Marshal(updatedSigs)
|
outgoingSignals, _ := json.Marshal(updatedSigs)
|
||||||
patchChan <- patch{patchTypeSignals, string(outgoingSignals)}
|
patchChan <- patch{patchTypeSignals, string(outgoingSignals)}
|
||||||
|
|||||||
@@ -83,11 +83,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
`)),
|
`)),
|
||||||
)
|
)
|
||||||
// Uncomment for inspector. Plugin candidate.
|
|
||||||
// v.AppendToFoot(
|
|
||||||
// h.Script(h.Src("https://cdn.jsdelivr.net/gh/dataSPA/dataSPA-inspector@latest/dataspa-inspector.bundled.js"), h.Type("module")),
|
|
||||||
// h.Raw("<dataspa-inspector/>"),
|
|
||||||
// )
|
|
||||||
rooms := NewRooms[Chat, UserInfo]("Clojure", "Dotnet", "Go", "Java", "JS", "Kotlin", "Python", "Rust")
|
rooms := NewRooms[Chat, UserInfo]("Clojure", "Dotnet", "Go", "Java", "JS", "Kotlin", "Python", "Rust")
|
||||||
rooms.Start()
|
rooms.Start()
|
||||||
|
|
||||||
@@ -144,24 +139,13 @@ func main() {
|
|||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
var tabs []h.H
|
var tabs []h.H
|
||||||
rooms.Visit(func(n string) {
|
rooms.Visit(func(n string) {
|
||||||
if n == roomNameString {
|
tabs = append(tabs, h.Li(
|
||||||
tabs = append(tabs, h.Li(
|
h.A(
|
||||||
h.A(
|
h.If(n == roomNameString, h.Attr("aria-current", "page")),
|
||||||
h.Href(""),
|
h.Text(n),
|
||||||
h.Attr("aria-current", "page"),
|
switchRoomAction.OnClick(WithSignal(roomName, n)),
|
||||||
h.Text(n),
|
),
|
||||||
switchRoomAction.OnClick(WithSignal(roomName, n)),
|
))
|
||||||
),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
tabs = append(tabs, h.Li(
|
|
||||||
h.A(
|
|
||||||
h.Href("#"),
|
|
||||||
h.Text(n),
|
|
||||||
switchRoomAction.OnClick(via.WithSignal(roomName, n)),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var messages []h.H
|
var messages []h.H
|
||||||
|
|||||||
@@ -46,4 +46,3 @@ func main() {
|
|||||||
|
|
||||||
v.Start()
|
v.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
via.go
6
via.go
@@ -174,6 +174,11 @@ func (v *V) registerCtx(c *Context) {
|
|||||||
}
|
}
|
||||||
v.contextRegistry[c.id] = c
|
v.contextRegistry[c.id] = c
|
||||||
v.logDebug(c, "new context added to registry")
|
v.logDebug(c, "new context added to registry")
|
||||||
|
v.summarize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) summarize() {
|
||||||
|
fmt.Println("Have", len(v.contextRegistry), "sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) unregisterCtx(id string) {
|
func (v *V) unregisterCtx(id string) {
|
||||||
@@ -184,6 +189,7 @@ func (v *V) unregisterCtx(id string) {
|
|||||||
}
|
}
|
||||||
v.logDebug(nil, "ctx '%s' removed from registry", id)
|
v.logDebug(nil, "ctx '%s' removed from registry", id)
|
||||||
delete(v.contextRegistry, id)
|
delete(v.contextRegistry, id)
|
||||||
|
v.summarize()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) getCtx(id string) (*Context, error) {
|
func (v *V) getCtx(id string) (*Context, error) {
|
||||||
|
|||||||
165
via_test.go
165
via_test.go
@@ -1,165 +1,86 @@
|
|||||||
package via
|
package via
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/andybalholm/brotli"
|
|
||||||
"github.com/go-via/via/h"
|
"github.com/go-via/via/h"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompressionBrotli(t *testing.T) {
|
func TestPageRoute(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
return h.Div(h.Text(strings.Repeat("Hello Via! ", 200)))
|
return h.Div(h.Text("Hello Via!"))
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("Accept-Encoding", "br")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
v.handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Equal(t, "br", w.Header().Get("Content-Encoding"))
|
|
||||||
|
|
||||||
reader := brotli.NewReader(w.Body)
|
|
||||||
decompressed, err := io.ReadAll(reader)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Contains(t, string(decompressed), "Hello Via!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompressionGzip(t *testing.T) {
|
|
||||||
v := New()
|
|
||||||
v.Page("/", func(c *Context) {
|
|
||||||
c.View(func() h.H {
|
|
||||||
return h.Div(h.Text(strings.Repeat("Hello Via! ", 200)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("Accept-Encoding", "gzip")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
v.handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Equal(t, "gzip", w.Header().Get("Content-Encoding"))
|
|
||||||
|
|
||||||
reader, err := gzip.NewReader(w.Body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
decompressed, err := io.ReadAll(reader)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Contains(t, string(decompressed), "Hello Via!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompressionNone(t *testing.T) {
|
|
||||||
v := New()
|
|
||||||
v.Page("/", func(c *Context) {
|
|
||||||
c.View(func() h.H {
|
|
||||||
return h.Div(h.Text(strings.Repeat("Hello Via! ", 200)))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
v.handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
||||||
assert.Contains(t, w.Body.String(), "Hello Via!")
|
assert.Contains(t, w.Body.String(), "Hello Via!")
|
||||||
|
assert.Contains(t, w.Body.String(), "<!doctype html>")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompressionMinSize(t *testing.T) {
|
func TestDatastarJS(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
|
||||||
c.View(func() h.H {
|
|
||||||
return h.Div(h.Text("Small"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("Accept-Encoding", "br")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
v.handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompressionLargeResponse(t *testing.T) {
|
|
||||||
v := New()
|
|
||||||
v.Page("/", func(c *Context) {
|
|
||||||
c.View(func() h.H {
|
|
||||||
return h.Div(h.Text(strings.Repeat("A", 2000)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("Accept-Encoding", "br")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
v.handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Equal(t, "br", w.Header().Get("Content-Encoding"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompressionDatastarJS(t *testing.T) {
|
|
||||||
v := New()
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/_datastar.js", nil)
|
req := httptest.NewRequest("GET", "/_datastar.js", nil)
|
||||||
req.Header.Set("Accept-Encoding", "br")
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
v.handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
assert.Equal(t, "br", w.Header().Get("Content-Encoding"))
|
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompressionBrotliPreferred(t *testing.T) {
|
func TestSignal(t *testing.T) {
|
||||||
|
var sig *signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
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.v.Interface())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
c.View(func() h.H {
|
||||||
return h.Div(h.Text(strings.Repeat("Hello Via! ", 200)))
|
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)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
v.handler.ServeHTTP(w, req)
|
body := w.Body.String()
|
||||||
|
assert.Contains(t, body, "data-on:click")
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Contains(t, body, "data-on:change__debounce.200ms")
|
||||||
assert.Equal(t, "br", w.Header().Get("Content-Encoding"))
|
assert.Contains(t, body, "data-on:keydown")
|
||||||
|
assert.Contains(t, body, "/_action/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompressionZstdDisabled(t *testing.T) {
|
func TestConfig(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Config(Options{DocumentTitle: "Test"})
|
||||||
c.View(func() h.H {
|
assert.Equal(t, "Test", v.cfg.DocumentTitle)
|
||||||
return h.Div(h.Text(strings.Repeat("Hello Via! ", 200)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("Accept-Encoding", "zstd, br, gzip")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
v.handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Equal(t, "br", w.Header().Get("Content-Encoding"), "Should use Brotli, not zstd")
|
|
||||||
assert.NotEqual(t, "zstd", w.Header().Get("Content-Encoding"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user