Script, GH action to check that all go files compile, and any tests pass. (#16)

This commit is contained in:
Jeff Winkler
2025-11-15 13:47:49 -05:00
committed by GitHub
parent 762635d7d9
commit f7b5b24dd5
8 changed files with 160 additions and 150 deletions

29
.github/workflows/ci.yml vendored Normal file
View 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
View File

@@ -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
View 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

View File

@@ -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)}

View File

@@ -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

View File

@@ -46,4 +46,3 @@ func main() {
v.Start() v.Start()
} }

6
via.go
View File

@@ -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) {

View File

@@ -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"))
} }